@strav/pdf 0.4.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -0
- package/package.json +51 -0
- package/src/color/cie.ts +61 -0
- package/src/color/color.ts +77 -0
- package/src/color/conversion.ts +26 -0
- package/src/color/device.ts +37 -0
- package/src/color/devicen.ts +74 -0
- package/src/color/icc.ts +103 -0
- package/src/color/index.ts +15 -0
- package/src/color/separation.ts +94 -0
- package/src/color/space.ts +47 -0
- package/src/content/content_stream.ts +373 -0
- package/src/content/graphics_state.ts +64 -0
- package/src/content/index.ts +16 -0
- package/src/content/operators.ts +70 -0
- package/src/content/path.ts +51 -0
- package/src/content/resources.ts +119 -0
- package/src/content/text_object.ts +140 -0
- package/src/document/catalog.ts +16 -0
- package/src/document/index.ts +13 -0
- package/src/document/object_table.ts +67 -0
- package/src/document/page.ts +74 -0
- package/src/document/page_tree.ts +78 -0
- package/src/document/pdf_document.ts +310 -0
- package/src/document/types.ts +65 -0
- package/src/document/xref.ts +68 -0
- package/src/ext-gstate/ext_gstate.ts +69 -0
- package/src/ext-gstate/index.ts +2 -0
- package/src/fonts/cff.ts +123 -0
- package/src/fonts/cid_encoding.ts +45 -0
- package/src/fonts/cmap_table.ts +180 -0
- package/src/fonts/font.ts +342 -0
- package/src/fonts/glyf.ts +59 -0
- package/src/fonts/hmtx.ts +21 -0
- package/src/fonts/index.ts +20 -0
- package/src/fonts/name_table.ts +50 -0
- package/src/fonts/os2.ts +41 -0
- package/src/fonts/sfnt.ts +224 -0
- package/src/fonts/standard_14.ts +132 -0
- package/src/fonts/subset.ts +221 -0
- package/src/fonts/to_unicode.ts +82 -0
- package/src/fonts/win_ansi.ts +69 -0
- package/src/images/image.ts +111 -0
- package/src/images/index.ts +6 -0
- package/src/images/jpeg.ts +103 -0
- package/src/images/png.ts +239 -0
- package/src/images/smask.ts +24 -0
- package/src/index.ts +57 -0
- package/src/metadata/index.ts +3 -0
- package/src/metadata/info_dict.ts +28 -0
- package/src/metadata/xmp.ts +110 -0
- package/src/objects/encode.ts +77 -0
- package/src/objects/index.ts +43 -0
- package/src/objects/indirect_ref.ts +17 -0
- package/src/objects/name.ts +50 -0
- package/src/objects/number.ts +43 -0
- package/src/objects/string.ts +136 -0
- package/src/objects/types.ts +86 -0
- package/src/output/buffer_sink.ts +40 -0
- package/src/output/byte_sink.ts +12 -0
- package/src/output/index.ts +3 -0
- package/src/output/stream_sink.ts +62 -0
- package/src/patterns/index.ts +10 -0
- package/src/patterns/shading.ts +162 -0
- package/src/patterns/tiling_pattern.ts +68 -0
- package/src/standards/context.ts +10 -0
- package/src/standards/index.ts +23 -0
- package/src/standards/pdf_a.ts +23 -0
- package/src/standards/pdf_x.ts +31 -0
- package/src/streams/ascii85.ts +61 -0
- package/src/streams/ascii_hex.ts +33 -0
- package/src/streams/flate.ts +17 -0
- package/src/streams/index.ts +9 -0
- package/src/streams/stream.ts +66 -0
- package/src/util/ascii.ts +63 -0
- package/src/util/binary.ts +71 -0
- package/src/util/errors.ts +61 -0
- package/src/util/index.ts +10 -0
- package/src/util/units.ts +24 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text object — the `BT … ET` block (spec §8, §10.7). Created by
|
|
3
|
+
* `ContentStream.text(cb)`; the callback drives it fluently. A font must be
|
|
4
|
+
* set before any `show`; the builder enforces BT/ET-only text operators and
|
|
5
|
+
* (via ContentStream) that no path is open and blocks don't nest.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { PdfGenError } from '../util/errors.ts'
|
|
9
|
+
import { formatNumber } from '../objects/number.ts'
|
|
10
|
+
import { encodeLiteral } from '../objects/string.ts'
|
|
11
|
+
import type { PdfFont } from '../fonts/font.ts'
|
|
12
|
+
import type { ResourceCollector } from './resources.ts'
|
|
13
|
+
|
|
14
|
+
function n(v: number): string {
|
|
15
|
+
return formatNumber(v)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A literal PDF string token built from already-encoded font bytes. */
|
|
19
|
+
function strToken(bytes: Uint8Array): string {
|
|
20
|
+
// encodeLiteral octal-escapes everything outside printable ASCII, so the
|
|
21
|
+
// result is pure ASCII; map bytes straight to chars (no TextDecoder).
|
|
22
|
+
const lit = encodeLiteral(bytes)
|
|
23
|
+
let s = ''
|
|
24
|
+
for (let i = 0; i < lit.length; i++) s += String.fromCharCode(lit[i]!)
|
|
25
|
+
return s
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A segment of a positioned run for `showRun` (spec §10.7). */
|
|
29
|
+
export type RunPart = { text: string } | { adjust: number }
|
|
30
|
+
|
|
31
|
+
export class TextObject {
|
|
32
|
+
private font?: PdfFont
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
private readonly emit: (line: string) => void,
|
|
36
|
+
private readonly resources: ResourceCollector
|
|
37
|
+
) {}
|
|
38
|
+
|
|
39
|
+
/** Select a font at `sizePt` points. */
|
|
40
|
+
setFont(font: PdfFont, sizePt: number): this {
|
|
41
|
+
this.font = font
|
|
42
|
+
const resName = this.resources.useFont(font)
|
|
43
|
+
this.emit(`/${resName} ${n(sizePt)} Tf`)
|
|
44
|
+
return this
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Set the **absolute** text position in user space (`1 0 0 1 x y Tm`).
|
|
49
|
+
* This resets the text matrix, so successive calls position independently —
|
|
50
|
+
* unlike `Td`, which is relative to the current line.
|
|
51
|
+
*/
|
|
52
|
+
moveTo(x: number, y: number): this {
|
|
53
|
+
this.emit(`1 0 0 1 ${n(x)} ${n(y)} Tm`)
|
|
54
|
+
return this
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Set the full text matrix (`a b c d e f Tm`) for scale/rotate/skew. */
|
|
58
|
+
setTextMatrix(a: number, b: number, c: number, d: number, e: number, f: number): this {
|
|
59
|
+
this.emit(`${n(a)} ${n(b)} ${n(c)} ${n(d)} ${n(e)} ${n(f)} Tm`)
|
|
60
|
+
return this
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Move to the next line, offset `(tx, ty)` from the current line (`Td`). */
|
|
64
|
+
nextLine(tx: number, ty: number): this {
|
|
65
|
+
this.emit(`${n(tx)} ${n(ty)} Td`)
|
|
66
|
+
return this
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
setLeading(leading: number): this {
|
|
70
|
+
this.emit(`${n(leading)} TL`)
|
|
71
|
+
return this
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Move to the next line using the current leading (`T*`). */
|
|
75
|
+
newLine(): this {
|
|
76
|
+
this.emit('T*')
|
|
77
|
+
return this
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
setCharSpacing(v: number): this {
|
|
81
|
+
this.emit(`${n(v)} Tc`)
|
|
82
|
+
return this
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
setWordSpacing(v: number): this {
|
|
86
|
+
this.emit(`${n(v)} Tw`)
|
|
87
|
+
return this
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Horizontal scaling as a percentage (`p Tz`). */
|
|
91
|
+
setHorizScale(percent: number): this {
|
|
92
|
+
this.emit(`${n(percent)} Tz`)
|
|
93
|
+
return this
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
setRise(v: number): this {
|
|
97
|
+
this.emit(`${n(v)} Ts`)
|
|
98
|
+
return this
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Text render mode 0–7 (`m Tr`). */
|
|
102
|
+
setRenderMode(mode: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7): this {
|
|
103
|
+
this.emit(`${mode} Tr`)
|
|
104
|
+
return this
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private requireFont(): PdfFont {
|
|
108
|
+
if (!this.font) {
|
|
109
|
+
throw new PdfGenError('PDF_NO_FONT', 'setFont() must be called before show()/showRun()')
|
|
110
|
+
}
|
|
111
|
+
return this.font
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Show a string (`(...) Tj`). */
|
|
115
|
+
show(text: string): this {
|
|
116
|
+
const font = this.requireFont()
|
|
117
|
+
this.emit(`${strToken(font.encode(text))} Tj`)
|
|
118
|
+
return this
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Show a positioned run (`[ ... ] TJ`). `adjust` is in 1/1000 em and is
|
|
123
|
+
* subtracted from the current position (spec §10.7).
|
|
124
|
+
*/
|
|
125
|
+
showRun(parts: RunPart[]): this {
|
|
126
|
+
const font = this.requireFont()
|
|
127
|
+
const items: string[] = []
|
|
128
|
+
for (const part of parts) {
|
|
129
|
+
if ('text' in part) items.push(strToken(font.encode(part.text)))
|
|
130
|
+
else items.push(n(part.adjust))
|
|
131
|
+
}
|
|
132
|
+
this.emit(`[${items.join(' ')}] TJ`)
|
|
133
|
+
return this
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Move to the next line and show (`T*` then `Tj`); needs leading set. */
|
|
137
|
+
newLineShow(text: string): this {
|
|
138
|
+
return this.newLine().show(text)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The document catalog (`/Type /Catalog`, spec §6.1). v1 populates the
|
|
3
|
+
* required `/Pages` and `/Version` entries. `/Metadata`, `/OutputIntents`,
|
|
4
|
+
* etc. are added by later milestones (M9/M11).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { IndirectRef } from '../objects/types.ts'
|
|
8
|
+
import { dict, name } from '../objects/types.ts'
|
|
9
|
+
|
|
10
|
+
export function buildCatalog(pagesRoot: IndirectRef) {
|
|
11
|
+
return dict({
|
|
12
|
+
Type: name('Catalog'),
|
|
13
|
+
Version: name('1.7'),
|
|
14
|
+
Pages: pagesRoot,
|
|
15
|
+
})
|
|
16
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { PdfDocument } from './pdf_document.ts'
|
|
2
|
+
export { Page } from './page.ts'
|
|
3
|
+
export type {
|
|
4
|
+
PageSize,
|
|
5
|
+
Rect,
|
|
6
|
+
ConformanceLevel,
|
|
7
|
+
DocumentInfo,
|
|
8
|
+
CreateOptions,
|
|
9
|
+
AddPageOptions,
|
|
10
|
+
} from './types.ts'
|
|
11
|
+
export { rectToBox } from './types.ts'
|
|
12
|
+
export { ObjectTable } from './object_table.ts'
|
|
13
|
+
export { serializeDocument } from './xref.ts'
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry of indirect objects (spec §5.2, §3.2).
|
|
3
|
+
*
|
|
4
|
+
* Implements the two-pass model: during the build pass every indirect object
|
|
5
|
+
* is allocated a number (1, 2, 3, …) and its value stored; during the
|
|
6
|
+
* serialize pass the values are emitted in number order and byte offsets
|
|
7
|
+
* recorded. Generation is always 0 in v1 (no incremental updates).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { PdfGenError } from '../util/errors.ts'
|
|
11
|
+
import type { PdfObject, IndirectRef } from '../objects/types.ts'
|
|
12
|
+
import { ref } from '../objects/types.ts'
|
|
13
|
+
|
|
14
|
+
export class ObjectTable {
|
|
15
|
+
/** num → object value. Object 0 is the free-list head, never stored here. */
|
|
16
|
+
private readonly slots = new Map<number, PdfObject>()
|
|
17
|
+
private next = 1
|
|
18
|
+
|
|
19
|
+
/** Reserve the next object number without assigning a value yet. */
|
|
20
|
+
allocate(): IndirectRef {
|
|
21
|
+
const num = this.next++
|
|
22
|
+
return ref(num, 0)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Assign (or replace) the value for a previously allocated number. */
|
|
26
|
+
set(r: IndirectRef, value: PdfObject): void {
|
|
27
|
+
if (r.num <= 0 || r.num >= this.next) {
|
|
28
|
+
throw new PdfGenError('PDF_INVALID_OBJECT', `Object ${r.num} was not allocated`)
|
|
29
|
+
}
|
|
30
|
+
this.slots.set(r.num, value)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Allocate + assign in one step; returns the reference. */
|
|
34
|
+
add(value: PdfObject): IndirectRef {
|
|
35
|
+
const r = this.allocate()
|
|
36
|
+
this.slots.set(r.num, value)
|
|
37
|
+
return r
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get(num: number): PdfObject | undefined {
|
|
41
|
+
return this.slots.get(num)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Highest allocated object number (0 if none). */
|
|
45
|
+
get maxNumber(): number {
|
|
46
|
+
return this.next - 1
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Total slot count including object 0 (the `/Size` trailer value). */
|
|
50
|
+
get size(): number {
|
|
51
|
+
return this.next
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Iterate assigned objects in ascending number order. */
|
|
55
|
+
*entries(): IterableIterator<[number, PdfObject]> {
|
|
56
|
+
for (let n = 1; n < this.next; n++) {
|
|
57
|
+
const v = this.slots.get(n)
|
|
58
|
+
if (v === undefined) {
|
|
59
|
+
throw new PdfGenError(
|
|
60
|
+
'PDF_INVALID_OBJECT',
|
|
61
|
+
`Object ${n} was allocated but never assigned a value`
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
yield [n, v]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A page (spec §6.3). Holds geometry and a lazily-created content stream.
|
|
3
|
+
* The page dictionary itself is built by the document serializer at save time.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ContentStream } from '../content/content_stream.ts'
|
|
7
|
+
import type { PageSize, Rect } from './types.ts'
|
|
8
|
+
|
|
9
|
+
export class Page {
|
|
10
|
+
readonly index: number
|
|
11
|
+
readonly size: PageSize
|
|
12
|
+
readonly rotation: 0 | 90 | 180 | 270
|
|
13
|
+
|
|
14
|
+
/** Optional page boxes (points). MediaBox defaults to the page size. */
|
|
15
|
+
private mediaBox?: Rect
|
|
16
|
+
private cropBox?: Rect
|
|
17
|
+
private bleedBox?: Rect
|
|
18
|
+
private trimBox?: Rect
|
|
19
|
+
private artBox?: Rect
|
|
20
|
+
|
|
21
|
+
private contentStream?: ContentStream
|
|
22
|
+
|
|
23
|
+
constructor(index: number, size: PageSize, rotation: 0 | 90 | 180 | 270 = 0) {
|
|
24
|
+
this.index = index
|
|
25
|
+
this.size = size
|
|
26
|
+
this.rotation = rotation
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
setMediaBox(box: Rect): void {
|
|
30
|
+
this.mediaBox = box
|
|
31
|
+
}
|
|
32
|
+
setCropBox(box: Rect): void {
|
|
33
|
+
this.cropBox = box
|
|
34
|
+
}
|
|
35
|
+
/** For PDF/X-4 (enforced in M11). */
|
|
36
|
+
setBleedBox(box: Rect): void {
|
|
37
|
+
this.bleedBox = box
|
|
38
|
+
}
|
|
39
|
+
/** For PDF/X-4 (enforced in M11). */
|
|
40
|
+
setTrimBox(box: Rect): void {
|
|
41
|
+
this.trimBox = box
|
|
42
|
+
}
|
|
43
|
+
setArtBox(box: Rect): void {
|
|
44
|
+
this.artBox = box
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Append-only content-stream builder for this page (spec §8). */
|
|
48
|
+
content(): ContentStream {
|
|
49
|
+
if (!this.contentStream) this.contentStream = new ContentStream()
|
|
50
|
+
return this.contentStream
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Internal: consumed by the document serializer ───────────────────────
|
|
54
|
+
|
|
55
|
+
/** @internal */
|
|
56
|
+
getContentStream(): ContentStream | undefined {
|
|
57
|
+
return this.contentStream
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @internal The effective MediaBox (defaults to the page size). */
|
|
61
|
+
getMediaBox(): Rect {
|
|
62
|
+
return this.mediaBox ?? { x: 0, y: 0, w: this.size.widthPt, h: this.size.heightPt }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @internal Optional boxes, present only when explicitly set. */
|
|
66
|
+
getOptionalBoxes(): { key: string; rect: Rect }[] {
|
|
67
|
+
const out: { key: string; rect: Rect }[] = []
|
|
68
|
+
if (this.cropBox) out.push({ key: 'CropBox', rect: this.cropBox })
|
|
69
|
+
if (this.bleedBox) out.push({ key: 'BleedBox', rect: this.bleedBox })
|
|
70
|
+
if (this.trimBox) out.push({ key: 'TrimBox', rect: this.trimBox })
|
|
71
|
+
if (this.artBox) out.push({ key: 'ArtBox', rect: this.artBox })
|
|
72
|
+
return out
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page tree builder (spec §6.2). A single-level tree (one `/Pages` node) until
|
|
3
|
+
* the page count exceeds 50, after which it is balanced into fan-out-of-8
|
|
4
|
+
* intermediate nodes. Intermediate `/Pages` node dictionaries are written into
|
|
5
|
+
* the {@link ObjectTable}; the caller builds the leaf `/Page` dictionaries
|
|
6
|
+
* using the returned parent map.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { IndirectRef, PdfObject } from '../objects/types.ts'
|
|
10
|
+
import { arr, dict, name, num } from '../objects/types.ts'
|
|
11
|
+
import type { ObjectTable } from './object_table.ts'
|
|
12
|
+
|
|
13
|
+
const FAN_OUT = 8
|
|
14
|
+
const SINGLE_LEVEL_MAX = 50
|
|
15
|
+
|
|
16
|
+
export interface PageTreeResult {
|
|
17
|
+
/** The `/Pages` root reference (goes in the catalog). */
|
|
18
|
+
rootRef: IndirectRef
|
|
19
|
+
/** leaf object number → its immediate parent `/Pages` node reference. */
|
|
20
|
+
parentOf: Map<number, IndirectRef>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function pagesNode(kids: IndirectRef[], count: number, parent: IndirectRef | null) {
|
|
24
|
+
const entries: Record<string, PdfObject> = {
|
|
25
|
+
Type: name('Pages'),
|
|
26
|
+
Kids: arr([...kids]),
|
|
27
|
+
Count: num(count),
|
|
28
|
+
}
|
|
29
|
+
if (parent) entries.Parent = parent
|
|
30
|
+
return dict(entries)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function chunk<T>(items: T[], size: number): T[][] {
|
|
34
|
+
const out: T[][] = []
|
|
35
|
+
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param leafRefs pre-allocated references for the leaf `/Page` objects, in
|
|
41
|
+
* page order.
|
|
42
|
+
*/
|
|
43
|
+
export function buildPageTree(table: ObjectTable, leafRefs: IndirectRef[]): PageTreeResult {
|
|
44
|
+
const parentOf = new Map<number, IndirectRef>()
|
|
45
|
+
|
|
46
|
+
if (leafRefs.length <= SINGLE_LEVEL_MAX) {
|
|
47
|
+
const rootRef = table.allocate()
|
|
48
|
+
for (const leaf of leafRefs) parentOf.set(leaf.num, rootRef)
|
|
49
|
+
table.set(rootRef, pagesNode(leafRefs, leafRefs.length, null))
|
|
50
|
+
return { rootRef, parentOf }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Multi-level: build intermediate nodes bottom-up, deferring each node's
|
|
54
|
+
// dictionary until its own parent is known.
|
|
55
|
+
const deferred = new Map<number, { ref: IndirectRef; kids: IndirectRef[]; count: number }>()
|
|
56
|
+
let level: { ref: IndirectRef; count: number }[] = leafRefs.map(ref => ({ ref, count: 1 }))
|
|
57
|
+
|
|
58
|
+
while (level.length > 1) {
|
|
59
|
+
const next: { ref: IndirectRef; count: number }[] = []
|
|
60
|
+
for (const group of chunk(level, FAN_OUT)) {
|
|
61
|
+
const nodeRef = table.allocate()
|
|
62
|
+
let count = 0
|
|
63
|
+
for (const child of group) {
|
|
64
|
+
parentOf.set(child.ref.num, nodeRef)
|
|
65
|
+
count += child.count
|
|
66
|
+
}
|
|
67
|
+
deferred.set(nodeRef.num, { ref: nodeRef, kids: group.map(g => g.ref), count })
|
|
68
|
+
next.push({ ref: nodeRef, count })
|
|
69
|
+
}
|
|
70
|
+
level = next
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const rootRef = level[0]!.ref
|
|
74
|
+
for (const { ref, kids, count } of deferred.values()) {
|
|
75
|
+
table.set(ref, pagesNode(kids, count, parentOf.get(ref.num) ?? null))
|
|
76
|
+
}
|
|
77
|
+
return { rootRef, parentOf }
|
|
78
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public facade (spec §16). Owns the object table, builds the document graph
|
|
3
|
+
* during the build pass, and emits bytes during the serialize pass.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
7
|
+
import { PdfGenError, UnsupportedFontError, ConformanceError } from '../util/errors.ts'
|
|
8
|
+
import type { IndirectRef, PdfObject } from '../objects/types.ts'
|
|
9
|
+
import { arr, dict, name, num } from '../objects/types.ts'
|
|
10
|
+
import { textString } from '../objects/string.ts'
|
|
11
|
+
import { encodeObject } from '../objects/encode.ts'
|
|
12
|
+
import { makeContentStream, makeStream } from '../streams/stream.ts'
|
|
13
|
+
import { parseIccProfile } from '../color/icc.ts'
|
|
14
|
+
import { buildInfoDict } from '../metadata/info_dict.ts'
|
|
15
|
+
import { buildXmpStream } from '../metadata/xmp.ts'
|
|
16
|
+
import { validateConformance } from '../standards/index.ts'
|
|
17
|
+
import { extGState, type ExtGStateOptions } from '../ext-gstate/ext_gstate.ts'
|
|
18
|
+
import { tilingPattern, type TilingPatternOptions } from '../patterns/tiling_pattern.ts'
|
|
19
|
+
import {
|
|
20
|
+
axialShading,
|
|
21
|
+
radialShading,
|
|
22
|
+
shadingPattern,
|
|
23
|
+
type AxialOptions,
|
|
24
|
+
type RadialOptions,
|
|
25
|
+
type Shading,
|
|
26
|
+
} from '../patterns/shading.ts'
|
|
27
|
+
import type { Writable } from 'node:stream'
|
|
28
|
+
import type { ByteSink } from '../output/byte_sink.ts'
|
|
29
|
+
import { BufferSink } from '../output/buffer_sink.ts'
|
|
30
|
+
import { StreamSink } from '../output/stream_sink.ts'
|
|
31
|
+
import { ObjectTable } from './object_table.ts'
|
|
32
|
+
import { buildPageTree } from './page_tree.ts'
|
|
33
|
+
import { buildCatalog } from './catalog.ts'
|
|
34
|
+
import { serializeDocument } from './xref.ts'
|
|
35
|
+
import { Page } from './page.ts'
|
|
36
|
+
import { rectToBox } from './types.ts'
|
|
37
|
+
import type {
|
|
38
|
+
AddPageOptions,
|
|
39
|
+
CreateOptions,
|
|
40
|
+
DocumentInfo,
|
|
41
|
+
ConformanceLevel,
|
|
42
|
+
OutputIntentConfig,
|
|
43
|
+
} from './types.ts'
|
|
44
|
+
|
|
45
|
+
const PRODUCER = '@strav/pdf'
|
|
46
|
+
|
|
47
|
+
export class PdfDocument {
|
|
48
|
+
private readonly info: DocumentInfo
|
|
49
|
+
private conformance: ConformanceLevel
|
|
50
|
+
private readonly creationDate: Date
|
|
51
|
+
private readonly fixedId?: Uint8Array
|
|
52
|
+
private readonly pages: Page[] = []
|
|
53
|
+
private outputIntent?: OutputIntentConfig
|
|
54
|
+
private saved = false
|
|
55
|
+
|
|
56
|
+
private constructor(opts: CreateOptions) {
|
|
57
|
+
this.info = opts.info ?? {}
|
|
58
|
+
this.conformance = opts.conformance ?? null
|
|
59
|
+
this.creationDate = opts.creationDate ?? new Date()
|
|
60
|
+
this.fixedId = opts.documentId ? normalizeId(opts.documentId) : undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Create a new, empty document. */
|
|
64
|
+
static create(opts: CreateOptions = {}): PdfDocument {
|
|
65
|
+
return new PdfDocument(opts)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
addPage(opts: AddPageOptions): Page {
|
|
69
|
+
if (this.saved) {
|
|
70
|
+
throw new PdfGenError('PDF_DOCUMENT_FINALIZED', 'Cannot add a page after save()')
|
|
71
|
+
}
|
|
72
|
+
const page = new Page(this.pages.length, opts.size, opts.rotation ?? 0)
|
|
73
|
+
this.pages.push(page)
|
|
74
|
+
return page
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Set the document's output intent (spec §9.3). Embeds the destination ICC
|
|
79
|
+
* profile and adds it to the catalog `/OutputIntents`. Required for PDF/X-4.
|
|
80
|
+
*/
|
|
81
|
+
setOutputIntent(cfg: OutputIntentConfig): this {
|
|
82
|
+
if (this.saved) {
|
|
83
|
+
throw new PdfGenError('PDF_DOCUMENT_FINALIZED', 'Cannot set output intent after save()')
|
|
84
|
+
}
|
|
85
|
+
parseIccProfile(cfg.destOutputProfile) // validate eagerly
|
|
86
|
+
this.outputIntent = cfg
|
|
87
|
+
return this
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Create an ExtGState (spec §13). Convenience for {@link extGState}. */
|
|
91
|
+
createExtGState(opts: ExtGStateOptions) {
|
|
92
|
+
return extGState(opts)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Create a tiling pattern (spec §12.1). */
|
|
96
|
+
createTilingPattern(opts: TilingPatternOptions) {
|
|
97
|
+
return tilingPattern(opts)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Create an axial (linear) shading (spec §12.2). */
|
|
101
|
+
createAxialShading(opts: AxialOptions) {
|
|
102
|
+
return axialShading(opts)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Create a radial shading (spec §12.2). */
|
|
106
|
+
createRadialShading(opts: RadialOptions) {
|
|
107
|
+
return radialShading(opts)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Wrap a shading as a fill/stroke pattern. */
|
|
111
|
+
createShadingPattern(shading: Shading, matrix?: number[]) {
|
|
112
|
+
return shadingPattern(shading, matrix)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Conformance target. */
|
|
116
|
+
getConformance(): ConformanceLevel {
|
|
117
|
+
return this.conformance
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Opt into a conformance mode (spec §15). Validated at `save()`, which
|
|
122
|
+
* throws `ConformanceError` listing every violation. A Standard-14 font
|
|
123
|
+
* used under any mode throws `UnsupportedFontError` (fail-fast).
|
|
124
|
+
*/
|
|
125
|
+
setConformance(level: ConformanceLevel): this {
|
|
126
|
+
if (this.saved) {
|
|
127
|
+
throw new PdfGenError('PDF_DOCUMENT_FINALIZED', 'Cannot set conformance after save()')
|
|
128
|
+
}
|
|
129
|
+
this.conformance = level
|
|
130
|
+
return this
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Serialize and return the complete PDF bytes (spec §3.2, §3.3). */
|
|
134
|
+
async save(): Promise<Uint8Array> {
|
|
135
|
+
const sink = new BufferSink()
|
|
136
|
+
this.finalize(sink)
|
|
137
|
+
return sink.toBytes()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Serialize, streaming to a Node `Writable` (spec §3.3) — no full-document
|
|
142
|
+
* buffer. Resolves once the stream has flushed; rejects on a stream error
|
|
143
|
+
* or a build/conformance error (the same as {@link save}).
|
|
144
|
+
*/
|
|
145
|
+
async saveToStream(writable: Writable): Promise<void> {
|
|
146
|
+
const sink = new StreamSink(writable)
|
|
147
|
+
this.finalize(sink)
|
|
148
|
+
await sink.done()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private finalize(sink: ByteSink): void {
|
|
152
|
+
if (this.saved) {
|
|
153
|
+
throw new PdfGenError('PDF_DOCUMENT_FINALIZED', 'Document already saved')
|
|
154
|
+
}
|
|
155
|
+
if (this.pages.length === 0) {
|
|
156
|
+
throw new PdfGenError('PDF_INVALID_PAGE', 'Document has no pages')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Conformance (spec §15) — before finalizing so the caller may fix and
|
|
160
|
+
// retry. Standard-14 fails fast (a distinct, typed error) ahead of the
|
|
161
|
+
// aggregate validation.
|
|
162
|
+
if (this.conformance) {
|
|
163
|
+
for (const page of this.pages) {
|
|
164
|
+
const cs = page.getContentStream()
|
|
165
|
+
for (const font of cs ? cs.usedFonts() : []) {
|
|
166
|
+
if (font.isStandard14) {
|
|
167
|
+
throw new UnsupportedFontError(
|
|
168
|
+
`Standard-14 font "${font.baseFont}" cannot be used under ${this.conformance}; ` +
|
|
169
|
+
'embed a TrueType/OpenType font instead'
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const profileColorSpace = this.outputIntent
|
|
175
|
+
? parseIccProfile(this.outputIntent.destOutputProfile).colorSpace
|
|
176
|
+
: undefined
|
|
177
|
+
const violations = validateConformance(this.conformance, {
|
|
178
|
+
pages: this.pages.map(p => ({
|
|
179
|
+
hasTrimOrArt: p
|
|
180
|
+
.getOptionalBoxes()
|
|
181
|
+
.some(b => b.key === 'TrimBox' || b.key === 'ArtBox'),
|
|
182
|
+
})),
|
|
183
|
+
outputIntent: { present: !!this.outputIntent, profileColorSpace },
|
|
184
|
+
})
|
|
185
|
+
if (violations.length) {
|
|
186
|
+
throw new ConformanceError(
|
|
187
|
+
`${this.conformance} conformance failed:\n - ${violations.join('\n - ')}`,
|
|
188
|
+
violations
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
this.saved = true
|
|
194
|
+
|
|
195
|
+
const table = new ObjectTable()
|
|
196
|
+
|
|
197
|
+
// Info dictionary (spec §14.1). ModDate == CreateDate for determinism.
|
|
198
|
+
const infoRef = table.add(buildInfoDict(this.info, this.creationDate, PRODUCER))
|
|
199
|
+
|
|
200
|
+
// Leaf page refs, then the page tree, then the leaf page dicts.
|
|
201
|
+
const leafRefs = this.pages.map(() => table.allocate())
|
|
202
|
+
const { rootRef, parentOf } = buildPageTree(table, leafRefs)
|
|
203
|
+
|
|
204
|
+
this.pages.forEach((page, i) => {
|
|
205
|
+
const leafRef = leafRefs[i]!
|
|
206
|
+
const parent = parentOf.get(leafRef.num)!
|
|
207
|
+
table.set(leafRef, this.buildPageDict(table, page, parent))
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
const catalog = buildCatalog(rootRef)
|
|
211
|
+
if (this.outputIntent) {
|
|
212
|
+
catalog.entries.set('OutputIntents', arr([this.buildOutputIntent(table)]))
|
|
213
|
+
}
|
|
214
|
+
// XMP is always present (spec §14.2) — uncompressed /Metadata stream.
|
|
215
|
+
catalog.entries.set(
|
|
216
|
+
'Metadata',
|
|
217
|
+
table.add(
|
|
218
|
+
buildXmpStream({
|
|
219
|
+
info: this.info,
|
|
220
|
+
creationDate: this.creationDate,
|
|
221
|
+
producer: PRODUCER,
|
|
222
|
+
conformance: this.conformance,
|
|
223
|
+
})
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
const catalogRef = table.add(catalog)
|
|
227
|
+
|
|
228
|
+
const id = this.computeId(infoRef, table)
|
|
229
|
+
|
|
230
|
+
serializeDocument({ table, root: catalogRef, info: infoRef, id, sink })
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── build helpers ───────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
private buildOutputIntent(table: ObjectTable): PdfObject {
|
|
236
|
+
const cfg = this.outputIntent!
|
|
237
|
+
const profile = parseIccProfile(cfg.destOutputProfile)
|
|
238
|
+
const profileRef = table.add(
|
|
239
|
+
makeStream(cfg.destOutputProfile, {
|
|
240
|
+
filter: 'FlateDecode',
|
|
241
|
+
extra: { N: num(profile.components) },
|
|
242
|
+
})
|
|
243
|
+
)
|
|
244
|
+
// These identifier fields are ASCII by convention (FOGRA39, URLs);
|
|
245
|
+
// PDF/X readers expect a readable literal string, not UTF-16BE.
|
|
246
|
+
const ascii = (s: string) => textString(s, { encoding: 'pdfdoc' })
|
|
247
|
+
const d = dict({
|
|
248
|
+
Type: name('OutputIntent'),
|
|
249
|
+
S: name(cfg.subtype),
|
|
250
|
+
OutputConditionIdentifier: ascii(cfg.outputConditionIdentifier),
|
|
251
|
+
DestOutputProfile: profileRef,
|
|
252
|
+
})
|
|
253
|
+
if (cfg.outputCondition) d.entries.set('OutputCondition', ascii(cfg.outputCondition))
|
|
254
|
+
if (cfg.registryName) d.entries.set('RegistryName', ascii(cfg.registryName))
|
|
255
|
+
if (cfg.info) d.entries.set('Info', ascii(cfg.info))
|
|
256
|
+
return d
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private buildPageDict(table: ObjectTable, page: Page, parent: IndirectRef) {
|
|
260
|
+
const mb = page.getMediaBox()
|
|
261
|
+
const entries: Record<string, PdfObject> = {
|
|
262
|
+
Type: name('Page'),
|
|
263
|
+
Parent: parent,
|
|
264
|
+
MediaBox: arr(rectToBox(mb).map(num)),
|
|
265
|
+
Resources: dict({}),
|
|
266
|
+
}
|
|
267
|
+
if (page.rotation !== 0) entries.Rotate = num(page.rotation)
|
|
268
|
+
for (const { key, rect } of page.getOptionalBoxes()) {
|
|
269
|
+
entries[key] = arr(rectToBox(rect).map(num))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const cs = page.getContentStream()
|
|
273
|
+
if (cs) {
|
|
274
|
+
cs.assertBalanced()
|
|
275
|
+
// Standard-14-under-conformance is checked up front in save().
|
|
276
|
+
const contentRef = table.add(makeContentStream(cs.toBytes()))
|
|
277
|
+
entries.Contents = contentRef
|
|
278
|
+
entries.Resources = cs.buildResources(table)
|
|
279
|
+
}
|
|
280
|
+
return dict(entries)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* `/ID` array (spec §6.5): a permanent id and a per-save id. With a fixed
|
|
285
|
+
* `documentId` (or fixed creationDate-driven content) the output is
|
|
286
|
+
* byte-identical across runs (spec §16.3).
|
|
287
|
+
*/
|
|
288
|
+
private computeId(infoRef: IndirectRef, table: ObjectTable): [Uint8Array, Uint8Array] {
|
|
289
|
+
if (this.fixedId) return [this.fixedId, this.fixedId]
|
|
290
|
+
const infoObj = table.get(infoRef.num)!
|
|
291
|
+
const permanent = createHash('md5').update(encodeObject(infoObj)).digest()
|
|
292
|
+
const perSave = new Uint8Array(randomBytes(16))
|
|
293
|
+
return [new Uint8Array(permanent), perSave]
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function normalizeId(id: string | Uint8Array): Uint8Array {
|
|
298
|
+
if (typeof id !== 'string') {
|
|
299
|
+
if (id.length !== 16) {
|
|
300
|
+
throw new PdfGenError('PDF_INVALID_OBJECT', 'documentId bytes must be exactly 16 long')
|
|
301
|
+
}
|
|
302
|
+
return id
|
|
303
|
+
}
|
|
304
|
+
if (!/^[0-9a-fA-F]{32}$/.test(id)) {
|
|
305
|
+
throw new PdfGenError('PDF_INVALID_OBJECT', 'documentId hex string must be 32 hex chars')
|
|
306
|
+
}
|
|
307
|
+
const out = new Uint8Array(16)
|
|
308
|
+
for (let i = 0; i < 16; i++) out[i] = parseInt(id.slice(i * 2, i * 2 + 2), 16)
|
|
309
|
+
return out
|
|
310
|
+
}
|