@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,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fluent, chainable content-stream builder (spec §8). Operator order matches
|
|
3
|
+
* call order and numbers use the shared serializer, so output is deterministic
|
|
4
|
+
* (§8.5). Graphics-state balance and current-path consumption are enforced;
|
|
5
|
+
* violations throw at `assertBalanced()` (called from `PdfDocument.save()`).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ascii } from '../util/ascii.ts'
|
|
9
|
+
import { PdfGenError } from '../util/errors.ts'
|
|
10
|
+
import { formatNumber } from '../objects/number.ts'
|
|
11
|
+
import { dict } from '../objects/types.ts'
|
|
12
|
+
import type { PdfDictionary } from '../objects/types.ts'
|
|
13
|
+
import type { ObjectTable } from '../document/object_table.ts'
|
|
14
|
+
import type { Color } from '../color/color.ts'
|
|
15
|
+
import type { PdfFont } from '../fonts/font.ts'
|
|
16
|
+
import type { PdfImage } from '../images/image.ts'
|
|
17
|
+
import type { Buildable } from './resources.ts'
|
|
18
|
+
import { fillColorOp, strokeColorOp } from '../color/device.ts'
|
|
19
|
+
import { OP } from './operators.ts'
|
|
20
|
+
import {
|
|
21
|
+
type GraphicsState,
|
|
22
|
+
type Matrix,
|
|
23
|
+
cloneState,
|
|
24
|
+
initialState,
|
|
25
|
+
multiply,
|
|
26
|
+
} from './graphics_state.ts'
|
|
27
|
+
import { PathTracker } from './path.ts'
|
|
28
|
+
import { ResourceCollector } from './resources.ts'
|
|
29
|
+
import { TextObject } from './text_object.ts'
|
|
30
|
+
|
|
31
|
+
function n(v: number): string {
|
|
32
|
+
return formatNumber(v)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class ContentStream {
|
|
36
|
+
private readonly lines: string[] = []
|
|
37
|
+
private readonly stack: GraphicsState[] = []
|
|
38
|
+
private state: GraphicsState = initialState()
|
|
39
|
+
private readonly path = new PathTracker()
|
|
40
|
+
private readonly resources = new ResourceCollector()
|
|
41
|
+
private inText = false
|
|
42
|
+
|
|
43
|
+
private emit(line: string): this {
|
|
44
|
+
this.lines.push(line)
|
|
45
|
+
return this
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Reject graphics/path operators that are illegal inside a BT…ET block. */
|
|
49
|
+
private assertNotInText(op: string): void {
|
|
50
|
+
if (this.inText) {
|
|
51
|
+
throw new PdfGenError(
|
|
52
|
+
'PDF_TEXT_STATE',
|
|
53
|
+
`${op} is not allowed inside a text() block (BT…ET)`
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Graphics state ──────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
save(): this {
|
|
61
|
+
this.assertNotInText('q (save)')
|
|
62
|
+
this.path.assertClear('q (save)')
|
|
63
|
+
this.stack.push(cloneState(this.state))
|
|
64
|
+
return this.emit(OP.save)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
restore(): this {
|
|
68
|
+
this.assertNotInText('Q (restore)')
|
|
69
|
+
this.path.assertClear('Q (restore)')
|
|
70
|
+
const prev = this.stack.pop()
|
|
71
|
+
if (!prev) {
|
|
72
|
+
throw new PdfGenError(
|
|
73
|
+
'PDF_UNBALANCED_GRAPHICS_STATE',
|
|
74
|
+
'restore() with no matching save()'
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
this.state = prev
|
|
78
|
+
return this.emit(OP.restore)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Concatenate a matrix to the CTM (`a b c d e f cm`). */
|
|
82
|
+
transform(m: Matrix): this {
|
|
83
|
+
this.assertNotInText('cm (transform)')
|
|
84
|
+
this.state.ctm = multiply(m, this.state.ctm)
|
|
85
|
+
return this.emit(`${m.map(n).join(' ')} ${OP.cm}`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
translate(tx: number, ty: number): this {
|
|
89
|
+
return this.transform([1, 0, 0, 1, tx, ty])
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
scale(sx: number, sy: number): this {
|
|
93
|
+
return this.transform([sx, 0, 0, sy, 0, 0])
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
setLineWidth(w: number): this {
|
|
97
|
+
this.state.lineWidth = w
|
|
98
|
+
return this.emit(`${n(w)} ${OP.lineWidth}`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
setLineCap(cap: 0 | 1 | 2): this {
|
|
102
|
+
this.state.lineCap = cap
|
|
103
|
+
return this.emit(`${cap} ${OP.lineCap}`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
setLineJoin(join: 0 | 1 | 2): this {
|
|
107
|
+
this.state.lineJoin = join
|
|
108
|
+
return this.emit(`${join} ${OP.lineJoin}`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
setMiterLimit(limit: number): this {
|
|
112
|
+
this.state.miterLimit = limit
|
|
113
|
+
return this.emit(`${n(limit)} ${OP.miterLimit}`)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
setDash(array: number[], phase = 0): this {
|
|
117
|
+
this.state.dash = { array: [...array], phase }
|
|
118
|
+
return this.emit(`[${array.map(n).join(' ')}] ${n(phase)} ${OP.dash}`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Color ───────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
setFillColor(c: Color): this {
|
|
124
|
+
this.state.fillColor = c
|
|
125
|
+
if (c.space === 'Managed') {
|
|
126
|
+
const cs = this.resources.useColorSpace(c.cs)
|
|
127
|
+
this.emit(`/${cs} ${OP.fillColorSpace}`)
|
|
128
|
+
return this.emit(`${c.comps.map(n).join(' ')} ${OP.fillColorN}`)
|
|
129
|
+
}
|
|
130
|
+
return this.emit(fillColorOp(c))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
setStrokeColor(c: Color): this {
|
|
134
|
+
this.state.strokeColor = c
|
|
135
|
+
if (c.space === 'Managed') {
|
|
136
|
+
const cs = this.resources.useColorSpace(c.cs)
|
|
137
|
+
this.emit(`/${cs} ${OP.strokeColorSpace}`)
|
|
138
|
+
return this.emit(`${c.comps.map(n).join(' ')} ${OP.strokeColorN}`)
|
|
139
|
+
}
|
|
140
|
+
return this.emit(strokeColorOp(c))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Transparency & patterns (spec §12, §13) ─────────────────────────────
|
|
144
|
+
|
|
145
|
+
/** Apply an ExtGState (alpha / blend mode) via the `gs` operator. */
|
|
146
|
+
setExtGState(gs: Buildable): this {
|
|
147
|
+
return this.emit(`/${this.resources.useExtGState(gs)} ${OP.extGState}`)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Use a tiling/shading pattern as the non-stroking (fill) color. */
|
|
151
|
+
setFillPattern(pattern: Buildable): this {
|
|
152
|
+
const p = this.resources.usePattern(pattern)
|
|
153
|
+
this.emit(`/Pattern ${OP.fillColorSpace}`)
|
|
154
|
+
return this.emit(`/${p} ${OP.fillColorN}`)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Use a tiling/shading pattern as the stroking color. */
|
|
158
|
+
setStrokePattern(pattern: Buildable): this {
|
|
159
|
+
const p = this.resources.usePattern(pattern)
|
|
160
|
+
this.emit(`/Pattern ${OP.strokeColorSpace}`)
|
|
161
|
+
return this.emit(`/${p} ${OP.strokeColorN}`)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Paint a shading over the current clip region (`sh` operator). */
|
|
165
|
+
shade(shading: Buildable): this {
|
|
166
|
+
this.assertNotInText('sh (shade)')
|
|
167
|
+
this.path.assertClear('sh (shade)')
|
|
168
|
+
return this.emit(`/${this.resources.useShading(shading)} ${OP.shading}`)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Path construction ───────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
moveTo(x: number, y: number): this {
|
|
174
|
+
this.assertNotInText('m (moveTo)')
|
|
175
|
+
this.path.open()
|
|
176
|
+
return this.emit(`${n(x)} ${n(y)} ${OP.moveTo}`)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
lineTo(x: number, y: number): this {
|
|
180
|
+
this.assertNotInText('l (lineTo)')
|
|
181
|
+
this.path.open()
|
|
182
|
+
return this.emit(`${n(x)} ${n(y)} ${OP.lineTo}`)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Cubic Bézier with both control points (`x1 y1 x2 y2 x3 y3 c`). */
|
|
186
|
+
curveTo(
|
|
187
|
+
x1: number,
|
|
188
|
+
y1: number,
|
|
189
|
+
x2: number,
|
|
190
|
+
y2: number,
|
|
191
|
+
x3: number,
|
|
192
|
+
y3: number
|
|
193
|
+
): this {
|
|
194
|
+
this.assertNotInText('c (curveTo)')
|
|
195
|
+
this.path.open()
|
|
196
|
+
return this.emit(
|
|
197
|
+
`${n(x1)} ${n(y1)} ${n(x2)} ${n(y2)} ${n(x3)} ${n(y3)} ${OP.curveTo}`
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
rect(x: number, y: number, w: number, h: number): this {
|
|
202
|
+
this.assertNotInText('re (rect)')
|
|
203
|
+
this.path.open()
|
|
204
|
+
return this.emit(`${n(x)} ${n(y)} ${n(w)} ${n(h)} ${OP.rect}`)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
closePath(): this {
|
|
208
|
+
this.assertNotInText('h (closePath)')
|
|
209
|
+
this.path.open()
|
|
210
|
+
return this.emit(OP.closePath)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Clipping ────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
clip(): this {
|
|
216
|
+
this.path.markClip()
|
|
217
|
+
return this.emit(OP.clip)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
clipEvenOdd(): this {
|
|
221
|
+
this.path.markClip()
|
|
222
|
+
return this.emit(OP.clipEvenOdd)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── Path painting (each consumes the current path) ──────────────────────
|
|
226
|
+
|
|
227
|
+
stroke(): this {
|
|
228
|
+
this.path.consume()
|
|
229
|
+
return this.emit(OP.stroke)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
closeStroke(): this {
|
|
233
|
+
this.path.consume()
|
|
234
|
+
return this.emit(OP.closeStroke)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
fill(): this {
|
|
238
|
+
this.path.consume()
|
|
239
|
+
return this.emit(OP.fill)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
fillEvenOdd(): this {
|
|
243
|
+
this.path.consume()
|
|
244
|
+
return this.emit(OP.fillEvenOdd)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
fillStroke(): this {
|
|
248
|
+
this.path.consume()
|
|
249
|
+
return this.emit(OP.fillStroke)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
closeFillStroke(): this {
|
|
253
|
+
this.path.consume()
|
|
254
|
+
return this.emit(OP.closeFillStroke)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** End the path with no fill or stroke (consumes a clip without painting). */
|
|
258
|
+
endPath(): this {
|
|
259
|
+
this.path.consume()
|
|
260
|
+
return this.emit(OP.endPath)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── Images (spec §11.3) ─────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Draw an image into the rectangle `(x, y)`–`(x+width, y+height)` in user
|
|
267
|
+
* space. The image's pixel dimensions are preserved in the XObject; the
|
|
268
|
+
* rectangle just sets the CTM scale. Emits `q w 0 0 h x y cm /Im Do Q`.
|
|
269
|
+
*/
|
|
270
|
+
drawImage(
|
|
271
|
+
image: PdfImage,
|
|
272
|
+
rect: { x: number; y: number; width: number; height: number }
|
|
273
|
+
): this {
|
|
274
|
+
this.assertNotInText('Do (drawImage)')
|
|
275
|
+
this.path.assertClear('Do (drawImage)')
|
|
276
|
+
const resName = this.resources.useImage(image)
|
|
277
|
+
this.emit(OP.save)
|
|
278
|
+
this.emit(`${n(rect.width)} 0 0 ${n(rect.height)} ${n(rect.x)} ${n(rect.y)} ${OP.cm}`)
|
|
279
|
+
this.emit(`/${resName} ${OP.xobject}`)
|
|
280
|
+
return this.emit(OP.restore)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── Text (spec §8, §10.7) ───────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* A text object: emits `BT`, runs `cb` with a {@link TextObject}, emits `ET`.
|
|
287
|
+
* Blocks must not nest and no path may be open (mirrors the q/Q guard).
|
|
288
|
+
*/
|
|
289
|
+
text(cb: (t: TextObject) => void): this {
|
|
290
|
+
this.assertNotInText('BT (text block)')
|
|
291
|
+
this.path.assertClear('BT (text block)')
|
|
292
|
+
this.inText = true
|
|
293
|
+
this.emit(OP.beginText)
|
|
294
|
+
try {
|
|
295
|
+
cb(new TextObject(line => this.emit(line), this.resources))
|
|
296
|
+
} finally {
|
|
297
|
+
// Always close the block, even if the callback threw, so the stream
|
|
298
|
+
// can never be left structurally unbalanced (BT without ET).
|
|
299
|
+
this.inText = false
|
|
300
|
+
this.emit(OP.endText)
|
|
301
|
+
}
|
|
302
|
+
return this
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ── Finalization ────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
/** Throws on unbalanced q/Q, an open text block, or an unconsumed path. */
|
|
308
|
+
assertBalanced(): void {
|
|
309
|
+
if (this.inText) {
|
|
310
|
+
throw new PdfGenError('PDF_TEXT_STATE', 'text() block did not close (missing ET)')
|
|
311
|
+
}
|
|
312
|
+
this.path.assertClear('end of content stream')
|
|
313
|
+
if (this.stack.length !== 0) {
|
|
314
|
+
throw new PdfGenError(
|
|
315
|
+
'PDF_UNBALANCED_GRAPHICS_STATE',
|
|
316
|
+
`${this.stack.length} unmatched save() call(s) at end of content stream`
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Fonts referenced by this stream, in first-use order. */
|
|
322
|
+
usedFonts(): PdfFont[] {
|
|
323
|
+
return this.resources.usedFonts().map(f => f.font)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Build the page `/Resources` dictionary. Font objects are added to the
|
|
328
|
+
* object table here and referenced by their stable resource names.
|
|
329
|
+
*/
|
|
330
|
+
buildResources(table: ObjectTable): PdfDictionary {
|
|
331
|
+
if (this.resources.isEmpty) return dict({})
|
|
332
|
+
const res = dict({})
|
|
333
|
+
const fonts = this.resources.usedFonts()
|
|
334
|
+
if (fonts.length) {
|
|
335
|
+
const fontDict = dict({})
|
|
336
|
+
for (const { name: resName, font } of fonts) {
|
|
337
|
+
fontDict.entries.set(resName, font.register(table))
|
|
338
|
+
}
|
|
339
|
+
res.entries.set('Font', fontDict)
|
|
340
|
+
}
|
|
341
|
+
const images = this.resources.usedImages()
|
|
342
|
+
if (images.length) {
|
|
343
|
+
const xobjDict = dict({})
|
|
344
|
+
for (const { name: resName, image } of images) {
|
|
345
|
+
xobjDict.entries.set(resName, image.register(table))
|
|
346
|
+
}
|
|
347
|
+
res.entries.set('XObject', xobjDict)
|
|
348
|
+
}
|
|
349
|
+
const spaces = this.resources.usedColorSpaces()
|
|
350
|
+
if (spaces.length) {
|
|
351
|
+
const csDict = dict({})
|
|
352
|
+
for (const { name: resName, cs } of spaces) {
|
|
353
|
+
csDict.entries.set(resName, cs.build(table))
|
|
354
|
+
}
|
|
355
|
+
res.entries.set('ColorSpace', csDict)
|
|
356
|
+
}
|
|
357
|
+
const fill = (key: string, items: { name: string; res: Buildable }[]) => {
|
|
358
|
+
if (!items.length) return
|
|
359
|
+
const d = dict({})
|
|
360
|
+
for (const { name: resName, res: r } of items) d.entries.set(resName, r.build(table))
|
|
361
|
+
res.entries.set(key, d)
|
|
362
|
+
}
|
|
363
|
+
fill('ExtGState', this.resources.usedExtGStates())
|
|
364
|
+
fill('Pattern', this.resources.usedPatterns())
|
|
365
|
+
fill('Shading', this.resources.usedShadings())
|
|
366
|
+
return res
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Raw, unfiltered content-stream bytes (filtering happens in the stream). */
|
|
370
|
+
toBytes(): Uint8Array {
|
|
371
|
+
return ascii(this.lines.length ? this.lines.join('\n') + '\n' : '')
|
|
372
|
+
}
|
|
373
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracked graphics state (spec §8.3). A snapshot is pushed on `q` and popped
|
|
3
|
+
* on `Q`. M1–M3 track the device-graphics fields; text-state fields are
|
|
4
|
+
* present but unused until M4/M5.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Color } from '../color/color.ts'
|
|
8
|
+
|
|
9
|
+
export type Matrix = [number, number, number, number, number, number]
|
|
10
|
+
|
|
11
|
+
export const IDENTITY: Matrix = [1, 0, 0, 1, 0, 0]
|
|
12
|
+
|
|
13
|
+
export interface GraphicsState {
|
|
14
|
+
ctm: Matrix
|
|
15
|
+
strokeColor: Color
|
|
16
|
+
fillColor: Color
|
|
17
|
+
lineWidth: number
|
|
18
|
+
lineCap: 0 | 1 | 2
|
|
19
|
+
lineJoin: 0 | 1 | 2
|
|
20
|
+
miterLimit: number
|
|
21
|
+
dash: { array: number[]; phase: number }
|
|
22
|
+
renderingIntent: string | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function initialState(): GraphicsState {
|
|
26
|
+
return {
|
|
27
|
+
ctm: [...IDENTITY],
|
|
28
|
+
strokeColor: { space: 'DeviceGray', g: 0 },
|
|
29
|
+
fillColor: { space: 'DeviceGray', g: 0 },
|
|
30
|
+
lineWidth: 1,
|
|
31
|
+
lineCap: 0,
|
|
32
|
+
lineJoin: 0,
|
|
33
|
+
miterLimit: 10,
|
|
34
|
+
dash: { array: [], phase: 0 },
|
|
35
|
+
renderingIntent: null,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Deep-enough copy for the save/restore stack (colors are immutable values). */
|
|
40
|
+
export function cloneState(s: GraphicsState): GraphicsState {
|
|
41
|
+
return {
|
|
42
|
+
ctm: [...s.ctm],
|
|
43
|
+
strokeColor: s.strokeColor,
|
|
44
|
+
fillColor: s.fillColor,
|
|
45
|
+
lineWidth: s.lineWidth,
|
|
46
|
+
lineCap: s.lineCap,
|
|
47
|
+
lineJoin: s.lineJoin,
|
|
48
|
+
miterLimit: s.miterLimit,
|
|
49
|
+
dash: { array: [...s.dash.array], phase: s.dash.phase },
|
|
50
|
+
renderingIntent: s.renderingIntent,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Multiply two matrices: result = a × b (PDF `cm` semantics, b applied first). */
|
|
55
|
+
export function multiply(a: Matrix, b: Matrix): Matrix {
|
|
56
|
+
return [
|
|
57
|
+
a[0] * b[0] + a[1] * b[2],
|
|
58
|
+
a[0] * b[1] + a[1] * b[3],
|
|
59
|
+
a[2] * b[0] + a[3] * b[2],
|
|
60
|
+
a[2] * b[1] + a[3] * b[3],
|
|
61
|
+
a[4] * b[0] + a[5] * b[2] + b[4],
|
|
62
|
+
a[4] * b[1] + a[5] * b[3] + b[5],
|
|
63
|
+
]
|
|
64
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { ContentStream } from './content_stream.ts'
|
|
2
|
+
export { OP } from './operators.ts'
|
|
3
|
+
export type { OperatorName } from './operators.ts'
|
|
4
|
+
export {
|
|
5
|
+
type GraphicsState,
|
|
6
|
+
type Matrix,
|
|
7
|
+
IDENTITY,
|
|
8
|
+
initialState,
|
|
9
|
+
cloneState,
|
|
10
|
+
multiply,
|
|
11
|
+
} from './graphics_state.ts'
|
|
12
|
+
export { PathTracker } from './path.ts'
|
|
13
|
+
export type { PathMode } from './path.ts'
|
|
14
|
+
export { TextObject } from './text_object.ts'
|
|
15
|
+
export type { RunPart } from './text_object.ts'
|
|
16
|
+
export { ResourceCollector } from './resources.ts'
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDF 1.7 content-stream operator names (spec §8.2). The builder emits these;
|
|
3
|
+
* keeping them as named constants prevents typos and documents coverage.
|
|
4
|
+
*
|
|
5
|
+
* `BX`/`EX` (compatibility) and the marked-content operators are intentionally
|
|
6
|
+
* absent — not generated in v1 (marked content arrives with tagged PDF, v1.1).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const OP = {
|
|
10
|
+
// Graphics state
|
|
11
|
+
save: 'q',
|
|
12
|
+
restore: 'Q',
|
|
13
|
+
cm: 'cm',
|
|
14
|
+
lineWidth: 'w',
|
|
15
|
+
lineCap: 'J',
|
|
16
|
+
lineJoin: 'j',
|
|
17
|
+
miterLimit: 'M',
|
|
18
|
+
dash: 'd',
|
|
19
|
+
renderingIntent: 'ri',
|
|
20
|
+
flatness: 'i',
|
|
21
|
+
extGState: 'gs',
|
|
22
|
+
|
|
23
|
+
// Path construction
|
|
24
|
+
moveTo: 'm',
|
|
25
|
+
lineTo: 'l',
|
|
26
|
+
curveTo: 'c',
|
|
27
|
+
curveToV: 'v',
|
|
28
|
+
curveToY: 'y',
|
|
29
|
+
rect: 're',
|
|
30
|
+
closePath: 'h',
|
|
31
|
+
|
|
32
|
+
// Path painting
|
|
33
|
+
stroke: 'S',
|
|
34
|
+
closeStroke: 's',
|
|
35
|
+
fill: 'f',
|
|
36
|
+
fillEvenOdd: 'f*',
|
|
37
|
+
fillStroke: 'B',
|
|
38
|
+
fillStrokeEvenOdd: 'B*',
|
|
39
|
+
closeFillStroke: 'b',
|
|
40
|
+
closeFillStrokeEvenOdd: 'b*',
|
|
41
|
+
endPath: 'n',
|
|
42
|
+
|
|
43
|
+
// Clipping
|
|
44
|
+
clip: 'W',
|
|
45
|
+
clipEvenOdd: 'W*',
|
|
46
|
+
|
|
47
|
+
// Color
|
|
48
|
+
strokeColorSpace: 'CS',
|
|
49
|
+
fillColorSpace: 'cs',
|
|
50
|
+
strokeColor: 'SC',
|
|
51
|
+
strokeColorN: 'SCN',
|
|
52
|
+
fillColor: 'sc',
|
|
53
|
+
fillColorN: 'scn',
|
|
54
|
+
strokeGray: 'G',
|
|
55
|
+
fillGray: 'g',
|
|
56
|
+
strokeRGB: 'RG',
|
|
57
|
+
fillRGB: 'rg',
|
|
58
|
+
strokeCMYK: 'K',
|
|
59
|
+
fillCMYK: 'k',
|
|
60
|
+
|
|
61
|
+
// XObjects / shading
|
|
62
|
+
xobject: 'Do',
|
|
63
|
+
shading: 'sh',
|
|
64
|
+
|
|
65
|
+
// Text (object scaffolding only in v1; full text in M4/M5)
|
|
66
|
+
beginText: 'BT',
|
|
67
|
+
endText: 'ET',
|
|
68
|
+
} as const
|
|
69
|
+
|
|
70
|
+
export type OperatorName = (typeof OP)[keyof typeof OP]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Current-path state machine (spec §8.4).
|
|
3
|
+
*
|
|
4
|
+
* The builder maintains an explicit path mode. Path-construction operators
|
|
5
|
+
* open a path; a painting/clip/end operator consumes it. Anything that would
|
|
6
|
+
* silently discard an open path (starting a `q`/`Q`/text block, or ending the
|
|
7
|
+
* stream) throws — silent path loss is a classic handwritten-PDF bug.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { PdfGenError } from '../util/errors.ts'
|
|
11
|
+
|
|
12
|
+
export type PathMode = 'none' | 'building' | 'clip-pending'
|
|
13
|
+
|
|
14
|
+
export class PathTracker {
|
|
15
|
+
private mode: PathMode = 'none'
|
|
16
|
+
|
|
17
|
+
/** A path-construction operator (m, l, c, v, y, re, h) was issued. */
|
|
18
|
+
open(): void {
|
|
19
|
+
if (this.mode === 'none') this.mode = 'building'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A clipping operator (W/W*) was issued; next paint op consumes the path. */
|
|
23
|
+
markClip(): void {
|
|
24
|
+
if (this.mode === 'none') {
|
|
25
|
+
throw new PdfGenError('PDF_NO_PATH', 'Clip operator with no current path')
|
|
26
|
+
}
|
|
27
|
+
this.mode = 'clip-pending'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A painting/end operator (S, f, B, n, …) consumes the path. */
|
|
31
|
+
consume(): void {
|
|
32
|
+
if (this.mode === 'none') {
|
|
33
|
+
throw new PdfGenError('PDF_NO_PATH', 'Painting operator with no current path')
|
|
34
|
+
}
|
|
35
|
+
this.mode = 'none'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Guard before an operation that must not discard an open path. */
|
|
39
|
+
assertClear(context: string): void {
|
|
40
|
+
if (this.mode !== 'none') {
|
|
41
|
+
throw new PdfGenError(
|
|
42
|
+
'PDF_PATH_NOT_CONSUMED',
|
|
43
|
+
`Unconsumed path before ${context}: paint it (stroke/fill/clip) or call endPath()`
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get isOpen(): boolean {
|
|
49
|
+
return this.mode !== 'none'
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-content-stream resource collector (spec §6.3 Resources). Hands out
|
|
3
|
+
* deterministic resource names (`F1`/`Im1`/…) on first use and keeps insertion
|
|
4
|
+
* order so the emitted `/Resources` dictionary is byte-stable.
|
|
5
|
+
*
|
|
6
|
+
* Fonts and image XObjects are collected; ExtGState/pattern categories are
|
|
7
|
+
* added in later milestones following the same pattern.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ObjectTable } from '../document/object_table.ts'
|
|
11
|
+
import type { PdfObject } from '../objects/types.ts'
|
|
12
|
+
import type { PdfFont } from '../fonts/font.ts'
|
|
13
|
+
import type { PdfImage } from '../images/image.ts'
|
|
14
|
+
import type { ManagedColorSpace } from '../color/space.ts'
|
|
15
|
+
|
|
16
|
+
/** Anything registered as a resource: a stable id + a builder. */
|
|
17
|
+
export interface Buildable {
|
|
18
|
+
readonly id: string
|
|
19
|
+
build(table: ObjectTable): PdfObject
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class ResourceCollector {
|
|
23
|
+
private readonly fonts = new Map<string, { name: string; font: PdfFont }>()
|
|
24
|
+
private readonly images = new Map<PdfImage, string>()
|
|
25
|
+
private readonly colorSpaces = new Map<string, { name: string; cs: ManagedColorSpace }>()
|
|
26
|
+
private readonly extGStates = new Map<string, { name: string; res: Buildable }>()
|
|
27
|
+
private readonly patterns = new Map<string, { name: string; res: Buildable }>()
|
|
28
|
+
private readonly shadings = new Map<string, { name: string; res: Buildable }>()
|
|
29
|
+
|
|
30
|
+
/** Register a font, returning its stable `/Font` resource name. */
|
|
31
|
+
useFont(font: PdfFont): string {
|
|
32
|
+
const existing = this.fonts.get(font.id)
|
|
33
|
+
if (existing) return existing.name
|
|
34
|
+
const name = `F${this.fonts.size + 1}`
|
|
35
|
+
this.fonts.set(font.id, { name, font })
|
|
36
|
+
return name
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Register an image, returning its stable `/XObject` resource name. */
|
|
40
|
+
useImage(image: PdfImage): string {
|
|
41
|
+
const existing = this.images.get(image)
|
|
42
|
+
if (existing) return existing
|
|
43
|
+
const name = `Im${this.images.size + 1}`
|
|
44
|
+
this.images.set(image, name)
|
|
45
|
+
return name
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Register a managed color space, returning its `/ColorSpace` name. */
|
|
49
|
+
useColorSpace(cs: ManagedColorSpace): string {
|
|
50
|
+
const existing = this.colorSpaces.get(cs.id)
|
|
51
|
+
if (existing) return existing.name
|
|
52
|
+
const name = `CS${this.colorSpaces.size + 1}`
|
|
53
|
+
this.colorSpaces.set(cs.id, { name, cs })
|
|
54
|
+
return name
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Fonts in first-use order. */
|
|
58
|
+
usedFonts(): { name: string; font: PdfFont }[] {
|
|
59
|
+
return [...this.fonts.values()]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Images in first-use order. */
|
|
63
|
+
usedImages(): { name: string; image: PdfImage }[] {
|
|
64
|
+
return [...this.images].map(([image, name]) => ({ name, image }))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Color spaces in first-use order. */
|
|
68
|
+
usedColorSpaces(): { name: string; cs: ManagedColorSpace }[] {
|
|
69
|
+
return [...this.colorSpaces.values()]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private use(
|
|
73
|
+
map: Map<string, { name: string; res: Buildable }>,
|
|
74
|
+
prefix: string,
|
|
75
|
+
res: Buildable
|
|
76
|
+
): string {
|
|
77
|
+
const existing = map.get(res.id)
|
|
78
|
+
if (existing) return existing.name
|
|
79
|
+
const name = `${prefix}${map.size + 1}`
|
|
80
|
+
map.set(res.id, { name, res })
|
|
81
|
+
return name
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Register an ExtGState → `/ExtGState` name (`GS1`, …). */
|
|
85
|
+
useExtGState(res: Buildable): string {
|
|
86
|
+
return this.use(this.extGStates, 'GS', res)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Register a pattern → `/Pattern` name (`P1`, …). */
|
|
90
|
+
usePattern(res: Buildable): string {
|
|
91
|
+
return this.use(this.patterns, 'P', res)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Register a shading → `/Shading` name (`Sh1`, …). */
|
|
95
|
+
useShading(res: Buildable): string {
|
|
96
|
+
return this.use(this.shadings, 'Sh', res)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
usedExtGStates(): { name: string; res: Buildable }[] {
|
|
100
|
+
return [...this.extGStates.values()]
|
|
101
|
+
}
|
|
102
|
+
usedPatterns(): { name: string; res: Buildable }[] {
|
|
103
|
+
return [...this.patterns.values()]
|
|
104
|
+
}
|
|
105
|
+
usedShadings(): { name: string; res: Buildable }[] {
|
|
106
|
+
return [...this.shadings.values()]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
get isEmpty(): boolean {
|
|
110
|
+
return (
|
|
111
|
+
this.fonts.size === 0 &&
|
|
112
|
+
this.images.size === 0 &&
|
|
113
|
+
this.colorSpaces.size === 0 &&
|
|
114
|
+
this.extGStates.size === 0 &&
|
|
115
|
+
this.patterns.size === 0 &&
|
|
116
|
+
this.shadings.size === 0
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
}
|