@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.
Files changed (80) hide show
  1. package/README.md +79 -0
  2. package/package.json +51 -0
  3. package/src/color/cie.ts +61 -0
  4. package/src/color/color.ts +77 -0
  5. package/src/color/conversion.ts +26 -0
  6. package/src/color/device.ts +37 -0
  7. package/src/color/devicen.ts +74 -0
  8. package/src/color/icc.ts +103 -0
  9. package/src/color/index.ts +15 -0
  10. package/src/color/separation.ts +94 -0
  11. package/src/color/space.ts +47 -0
  12. package/src/content/content_stream.ts +373 -0
  13. package/src/content/graphics_state.ts +64 -0
  14. package/src/content/index.ts +16 -0
  15. package/src/content/operators.ts +70 -0
  16. package/src/content/path.ts +51 -0
  17. package/src/content/resources.ts +119 -0
  18. package/src/content/text_object.ts +140 -0
  19. package/src/document/catalog.ts +16 -0
  20. package/src/document/index.ts +13 -0
  21. package/src/document/object_table.ts +67 -0
  22. package/src/document/page.ts +74 -0
  23. package/src/document/page_tree.ts +78 -0
  24. package/src/document/pdf_document.ts +310 -0
  25. package/src/document/types.ts +65 -0
  26. package/src/document/xref.ts +68 -0
  27. package/src/ext-gstate/ext_gstate.ts +69 -0
  28. package/src/ext-gstate/index.ts +2 -0
  29. package/src/fonts/cff.ts +123 -0
  30. package/src/fonts/cid_encoding.ts +45 -0
  31. package/src/fonts/cmap_table.ts +180 -0
  32. package/src/fonts/font.ts +342 -0
  33. package/src/fonts/glyf.ts +59 -0
  34. package/src/fonts/hmtx.ts +21 -0
  35. package/src/fonts/index.ts +20 -0
  36. package/src/fonts/name_table.ts +50 -0
  37. package/src/fonts/os2.ts +41 -0
  38. package/src/fonts/sfnt.ts +224 -0
  39. package/src/fonts/standard_14.ts +132 -0
  40. package/src/fonts/subset.ts +221 -0
  41. package/src/fonts/to_unicode.ts +82 -0
  42. package/src/fonts/win_ansi.ts +69 -0
  43. package/src/images/image.ts +111 -0
  44. package/src/images/index.ts +6 -0
  45. package/src/images/jpeg.ts +103 -0
  46. package/src/images/png.ts +239 -0
  47. package/src/images/smask.ts +24 -0
  48. package/src/index.ts +57 -0
  49. package/src/metadata/index.ts +3 -0
  50. package/src/metadata/info_dict.ts +28 -0
  51. package/src/metadata/xmp.ts +110 -0
  52. package/src/objects/encode.ts +77 -0
  53. package/src/objects/index.ts +43 -0
  54. package/src/objects/indirect_ref.ts +17 -0
  55. package/src/objects/name.ts +50 -0
  56. package/src/objects/number.ts +43 -0
  57. package/src/objects/string.ts +136 -0
  58. package/src/objects/types.ts +86 -0
  59. package/src/output/buffer_sink.ts +40 -0
  60. package/src/output/byte_sink.ts +12 -0
  61. package/src/output/index.ts +3 -0
  62. package/src/output/stream_sink.ts +62 -0
  63. package/src/patterns/index.ts +10 -0
  64. package/src/patterns/shading.ts +162 -0
  65. package/src/patterns/tiling_pattern.ts +68 -0
  66. package/src/standards/context.ts +10 -0
  67. package/src/standards/index.ts +23 -0
  68. package/src/standards/pdf_a.ts +23 -0
  69. package/src/standards/pdf_x.ts +31 -0
  70. package/src/streams/ascii85.ts +61 -0
  71. package/src/streams/ascii_hex.ts +33 -0
  72. package/src/streams/flate.ts +17 -0
  73. package/src/streams/index.ts +9 -0
  74. package/src/streams/stream.ts +66 -0
  75. package/src/util/ascii.ts +63 -0
  76. package/src/util/binary.ts +71 -0
  77. package/src/util/errors.ts +61 -0
  78. package/src/util/index.ts +10 -0
  79. package/src/util/units.ts +24 -0
  80. 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
+ }