@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,10 @@
|
|
|
1
|
+
export { tilingPattern } from './tiling_pattern.ts'
|
|
2
|
+
export type { TilingPattern, TilingPatternOptions } from './tiling_pattern.ts'
|
|
3
|
+
export { axialShading, radialShading, shadingPattern } from './shading.ts'
|
|
4
|
+
export type {
|
|
5
|
+
Shading,
|
|
6
|
+
ShadingPattern,
|
|
7
|
+
ColorStop,
|
|
8
|
+
AxialOptions,
|
|
9
|
+
RadialOptions,
|
|
10
|
+
} from './shading.ts'
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Axial (type 2) and radial (type 3) shadings (spec §12.2). The color ramp is
|
|
3
|
+
* a Type-2 exponential function for two stops, or a Type-3 stitching function
|
|
4
|
+
* for more. Types 1 and 4–7 are reachable by adding raw objects.
|
|
5
|
+
*
|
|
6
|
+
* A `Shading` paints via the `sh` operator; wrap it with `shadingPattern` to
|
|
7
|
+
* use it as a fill/stroke pattern (PatternType 2).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { PdfGenError } from '../util/errors.ts'
|
|
11
|
+
import { arr, bool, dict, name, num } from '../objects/types.ts'
|
|
12
|
+
import type { PdfObject } from '../objects/types.ts'
|
|
13
|
+
import type { ObjectTable } from '../document/object_table.ts'
|
|
14
|
+
import type { DeviceColor } from '../color/color.ts'
|
|
15
|
+
import { deviceComponents, deviceSpaceName } from '../color/color.ts'
|
|
16
|
+
|
|
17
|
+
export interface ColorStop {
|
|
18
|
+
/** Position in [0,1]. */
|
|
19
|
+
offset: number
|
|
20
|
+
color: DeviceColor
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buildFunction(stops: ColorStop[]): PdfObject {
|
|
24
|
+
if (stops.length < 2) {
|
|
25
|
+
throw new PdfGenError('PDF_INVALID_COLOR', 'A shading needs at least two color stops')
|
|
26
|
+
}
|
|
27
|
+
const space = stops[0]!.color.space
|
|
28
|
+
for (const s of stops) {
|
|
29
|
+
if (s.color.space !== space) {
|
|
30
|
+
throw new PdfGenError('PDF_INVALID_COLOR', 'All shading stops must share one color space')
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const seg = (a: DeviceColor, b: DeviceColor): PdfObject =>
|
|
34
|
+
dict({
|
|
35
|
+
FunctionType: num(2),
|
|
36
|
+
Domain: arr([num(0), num(1)]),
|
|
37
|
+
C0: arr(deviceComponents(a).map(num)),
|
|
38
|
+
C1: arr(deviceComponents(b).map(num)),
|
|
39
|
+
N: num(1),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
if (stops.length === 2) return seg(stops[0]!.color, stops[1]!.color)
|
|
43
|
+
|
|
44
|
+
// Type-3 stitching of consecutive Type-2 segments.
|
|
45
|
+
const fns: PdfObject[] = []
|
|
46
|
+
const bounds: PdfObject[] = []
|
|
47
|
+
const encode: PdfObject[] = []
|
|
48
|
+
for (let i = 0; i < stops.length - 1; i++) {
|
|
49
|
+
fns.push(seg(stops[i]!.color, stops[i + 1]!.color))
|
|
50
|
+
encode.push(num(0), num(1))
|
|
51
|
+
if (i > 0) bounds.push(num(stops[i]!.offset))
|
|
52
|
+
}
|
|
53
|
+
return dict({
|
|
54
|
+
FunctionType: num(3),
|
|
55
|
+
Domain: arr([num(0), num(1)]),
|
|
56
|
+
Functions: arr(fns),
|
|
57
|
+
Bounds: arr(bounds),
|
|
58
|
+
Encode: arr(encode),
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function colorSpaceName(stops: ColorStop[]): string {
|
|
63
|
+
return deviceSpaceName(stops[0]!.color)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeStops(colors: DeviceColor[] | ColorStop[]): ColorStop[] {
|
|
67
|
+
const first = colors[0]
|
|
68
|
+
if (first && 'color' in first) return colors as ColorStop[]
|
|
69
|
+
const cs = colors as DeviceColor[]
|
|
70
|
+
return cs.map((color, i) => ({ offset: i / (cs.length - 1), color }))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
class Shading {
|
|
74
|
+
readonly id: string
|
|
75
|
+
constructor(
|
|
76
|
+
private readonly shadingType: 2 | 3,
|
|
77
|
+
private readonly coords: number[],
|
|
78
|
+
private readonly stops: ColorStop[],
|
|
79
|
+
private readonly extend: [boolean, boolean],
|
|
80
|
+
tag: number
|
|
81
|
+
) {
|
|
82
|
+
this.id = `sh${shadingType}:${tag}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
build(_table: ObjectTable): PdfObject {
|
|
86
|
+
return dict({
|
|
87
|
+
ShadingType: num(this.shadingType),
|
|
88
|
+
ColorSpace: name(colorSpaceName(this.stops)),
|
|
89
|
+
Coords: arr(this.coords.map(num)),
|
|
90
|
+
Function: buildFunction(this.stops),
|
|
91
|
+
Extend: arr([bool(this.extend[0]), bool(this.extend[1])]),
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let shCounter = 0
|
|
97
|
+
|
|
98
|
+
export interface AxialOptions {
|
|
99
|
+
from: [number, number]
|
|
100
|
+
to: [number, number]
|
|
101
|
+
colors: DeviceColor[] | ColorStop[]
|
|
102
|
+
extend?: [boolean, boolean]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A type-2 (axial / linear-gradient) shading. */
|
|
106
|
+
export function axialShading(o: AxialOptions): Shading {
|
|
107
|
+
return new Shading(
|
|
108
|
+
2,
|
|
109
|
+
[o.from[0], o.from[1], o.to[0], o.to[1]],
|
|
110
|
+
normalizeStops(o.colors),
|
|
111
|
+
o.extend ?? [true, true],
|
|
112
|
+
shCounter++
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface RadialOptions {
|
|
117
|
+
from: { x: number; y: number; r: number }
|
|
118
|
+
to: { x: number; y: number; r: number }
|
|
119
|
+
colors: DeviceColor[] | ColorStop[]
|
|
120
|
+
extend?: [boolean, boolean]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A type-3 (radial) shading. */
|
|
124
|
+
export function radialShading(o: RadialOptions): Shading {
|
|
125
|
+
return new Shading(
|
|
126
|
+
3,
|
|
127
|
+
[o.from.x, o.from.y, o.from.r, o.to.x, o.to.y, o.to.r],
|
|
128
|
+
normalizeStops(o.colors),
|
|
129
|
+
o.extend ?? [true, true],
|
|
130
|
+
shCounter++
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
class ShadingPattern {
|
|
135
|
+
readonly id: string
|
|
136
|
+
constructor(
|
|
137
|
+
private readonly shading: Shading,
|
|
138
|
+
private readonly matrix: number[] | undefined,
|
|
139
|
+
tag: number
|
|
140
|
+
) {
|
|
141
|
+
this.id = `shp:${shading.id}:${tag}`
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
build(table: ObjectTable): PdfObject {
|
|
145
|
+
const d = dict({
|
|
146
|
+
Type: name('Pattern'),
|
|
147
|
+
PatternType: num(2),
|
|
148
|
+
Shading: this.shading.build(table),
|
|
149
|
+
})
|
|
150
|
+
if (this.matrix) d.entries.set('Matrix', arr(this.matrix.map(num)))
|
|
151
|
+
return table.add(d)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let shpCounter = 0
|
|
156
|
+
|
|
157
|
+
/** Wrap a shading as a pattern (use with `setFillPattern`/`setStrokePattern`). */
|
|
158
|
+
export function shadingPattern(shading: Shading, matrix?: number[]): ShadingPattern {
|
|
159
|
+
return new ShadingPattern(shading, matrix, shpCounter++)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export type { Shading, ShadingPattern }
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiling pattern (spec §12.1). The draw callback receives a content-stream
|
|
3
|
+
* builder scoped to the pattern cell; its own resources (colors, images,
|
|
4
|
+
* fonts) are collected into the pattern's `/Resources`. A colored pattern
|
|
5
|
+
* (PaintType 1) carries its own color; an uncolored one (PaintType 2) is
|
|
6
|
+
* tinted by the caller's current color.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { arr, name, num } from '../objects/types.ts'
|
|
10
|
+
import type { PdfObject } from '../objects/types.ts'
|
|
11
|
+
import type { ObjectTable } from '../document/object_table.ts'
|
|
12
|
+
import { makeStream } from '../streams/stream.ts'
|
|
13
|
+
import { ContentStream } from '../content/content_stream.ts'
|
|
14
|
+
|
|
15
|
+
export interface TilingPatternOptions {
|
|
16
|
+
/** Pattern cell bounding box `[llx lly urx ury]`. */
|
|
17
|
+
bbox: [number, number, number, number]
|
|
18
|
+
xStep: number
|
|
19
|
+
yStep: number
|
|
20
|
+
/** `'colored'` (default) carries its own color; `'uncolored'` is tinted. */
|
|
21
|
+
paintType?: 'colored' | 'uncolored'
|
|
22
|
+
/** Optional pattern→page transform. */
|
|
23
|
+
matrix?: number[]
|
|
24
|
+
draw: (c: ContentStream) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class TilingPattern {
|
|
28
|
+
readonly id: string
|
|
29
|
+
/** `'colored'` patterns set color in the cell; `'uncolored'` are tinted. */
|
|
30
|
+
readonly paintType: 'colored' | 'uncolored'
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
private readonly opts: TilingPatternOptions,
|
|
34
|
+
tag: number
|
|
35
|
+
) {
|
|
36
|
+
this.paintType = opts.paintType ?? 'colored'
|
|
37
|
+
this.id = `tile:${tag}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
build(table: ObjectTable): PdfObject {
|
|
41
|
+
const cell = new ContentStream()
|
|
42
|
+
this.opts.draw(cell)
|
|
43
|
+
cell.assertBalanced()
|
|
44
|
+
const resources = cell.buildResources(table) // children added to table
|
|
45
|
+
const [x0, y0, x1, y1] = this.opts.bbox
|
|
46
|
+
const extra: Record<string, PdfObject> = {
|
|
47
|
+
Type: name('Pattern'),
|
|
48
|
+
PatternType: num(1),
|
|
49
|
+
PaintType: num(this.paintType === 'colored' ? 1 : 2),
|
|
50
|
+
TilingType: num(1),
|
|
51
|
+
BBox: arr([x0, y0, x1, y1].map(num)),
|
|
52
|
+
XStep: num(this.opts.xStep),
|
|
53
|
+
YStep: num(this.opts.yStep),
|
|
54
|
+
Resources: resources,
|
|
55
|
+
}
|
|
56
|
+
if (this.opts.matrix) extra.Matrix = arr(this.opts.matrix.map(num))
|
|
57
|
+
return table.add(makeStream(cell.toBytes(), { filter: 'FlateDecode', extra }))
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let tileCounter = 0
|
|
62
|
+
|
|
63
|
+
/** Create a tiling pattern (use with `ContentStream.setFillPattern`). */
|
|
64
|
+
export function tilingPattern(opts: TilingPatternOptions): TilingPattern {
|
|
65
|
+
return new TilingPattern(opts, tileCounter++)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type { TilingPattern }
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Inputs to the conformance validators (gathered by PdfDocument at save). */
|
|
2
|
+
export interface ConformanceContext {
|
|
3
|
+
/** One entry per page; whether it has a TrimBox or ArtBox set. */
|
|
4
|
+
pages: { hasTrimOrArt: boolean }[]
|
|
5
|
+
outputIntent: {
|
|
6
|
+
present: boolean
|
|
7
|
+
/** ICC data color space of the destination profile, if present. */
|
|
8
|
+
profileColorSpace?: 'GRAY' | 'RGB' | 'CMYK' | 'Lab'
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conformance dispatch (spec §15). `setConformance(...)` opts in; validation
|
|
3
|
+
* runs at `save()` and a non-empty result becomes a `ConformanceError`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ConformanceLevel } from '../document/types.ts'
|
|
7
|
+
import type { ConformanceContext } from './context.ts'
|
|
8
|
+
import { validatePdfA } from './pdf_a.ts'
|
|
9
|
+
import { validatePdfX } from './pdf_x.ts'
|
|
10
|
+
|
|
11
|
+
export type { ConformanceContext } from './context.ts'
|
|
12
|
+
export { validatePdfA } from './pdf_a.ts'
|
|
13
|
+
export { validatePdfX } from './pdf_x.ts'
|
|
14
|
+
|
|
15
|
+
/** Returns all conformance violations for `level` (empty = conforms). */
|
|
16
|
+
export function validateConformance(
|
|
17
|
+
level: ConformanceLevel,
|
|
18
|
+
ctx: ConformanceContext
|
|
19
|
+
): string[] {
|
|
20
|
+
if (level === 'PDF/A-2b') return validatePdfA(ctx)
|
|
21
|
+
if (level === 'PDF/X-4') return validatePdfX(ctx)
|
|
22
|
+
return []
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDF/A-2b conformance checks (spec §15.1). Returns the list of violations
|
|
3
|
+
* (empty = conforms). Font rules — all fonts embedded, ToUnicode present, no
|
|
4
|
+
* Standard-14 — are enforced earlier: a Standard-14 font under a conformance
|
|
5
|
+
* mode throws `UnsupportedFontError`, and embedded fonts always carry a
|
|
6
|
+
* ToUnicode CMap and are subsetted, so they need no check here.
|
|
7
|
+
*
|
|
8
|
+
* Not checked (out of scope / not producible by this library): encryption,
|
|
9
|
+
* LZWDecode, JavaScript, external references — none are ever emitted.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ConformanceContext } from './context.ts'
|
|
13
|
+
|
|
14
|
+
export function validatePdfA(ctx: ConformanceContext): string[] {
|
|
15
|
+
const v: string[] = []
|
|
16
|
+
if (!ctx.outputIntent.present) {
|
|
17
|
+
v.push(
|
|
18
|
+
'PDF/A-2b requires an OutputIntent (the all-DeviceGray exception is not ' +
|
|
19
|
+
'auto-detected — call setOutputIntent)'
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
return v
|
|
23
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDF/X-4 conformance checks (spec §15.2). Returns the list of violations.
|
|
3
|
+
*
|
|
4
|
+
* Enforced here: an OutputIntent with a CMYK/Gray destination profile, and a
|
|
5
|
+
* TrimBox or ArtBox on every page (MediaBox always exists). Embedded-and-
|
|
6
|
+
* subsetted fonts and the `/ID` array are guaranteed by the library; the
|
|
7
|
+
* no-Standard-14 rule throws `UnsupportedFontError` earlier.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ConformanceContext } from './context.ts'
|
|
11
|
+
|
|
12
|
+
export function validatePdfX(ctx: ConformanceContext): string[] {
|
|
13
|
+
const v: string[] = []
|
|
14
|
+
|
|
15
|
+
if (!ctx.outputIntent.present) {
|
|
16
|
+
v.push('PDF/X-4 requires an OutputIntent — call setOutputIntent()')
|
|
17
|
+
} else {
|
|
18
|
+
const cs = ctx.outputIntent.profileColorSpace
|
|
19
|
+
if (cs !== 'CMYK' && cs !== 'GRAY') {
|
|
20
|
+
v.push(`PDF/X-4 output intent profile must be CMYK or Gray (got ${cs ?? 'unknown'})`)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
ctx.pages.forEach((p, i) => {
|
|
25
|
+
if (!p.hasTrimOrArt) {
|
|
26
|
+
v.push(`PDF/X-4 page ${i + 1} requires a TrimBox or ArtBox (MediaBox alone is not enough)`)
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
return v
|
|
31
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ASCII85Decode (spec §7.1). Available; not used by default. Adobe variant:
|
|
3
|
+
* 4-byte groups → 5 chars offset by '!' (0x21); an all-zero group is 'z';
|
|
4
|
+
* the stream ends with the `~>` EOD marker.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export function ascii85Encode(data: Uint8Array): Uint8Array {
|
|
8
|
+
const out: number[] = []
|
|
9
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
10
|
+
const len = Math.min(4, data.length - i)
|
|
11
|
+
let word = 0
|
|
12
|
+
for (let j = 0; j < 4; j++) {
|
|
13
|
+
word = (word * 256 + (j < len ? data[i + j]! : 0)) >>> 0
|
|
14
|
+
}
|
|
15
|
+
if (len === 4 && word === 0) {
|
|
16
|
+
out.push(0x7a) // 'z'
|
|
17
|
+
continue
|
|
18
|
+
}
|
|
19
|
+
const group = [0, 0, 0, 0, 0]
|
|
20
|
+
for (let k = 4; k >= 0; k--) {
|
|
21
|
+
group[k] = word % 85
|
|
22
|
+
word = Math.floor(word / 85)
|
|
23
|
+
}
|
|
24
|
+
for (let k = 0; k < len + 1; k++) out.push(group[k]! + 0x21)
|
|
25
|
+
}
|
|
26
|
+
out.push(0x7e, 0x3e) // ~>
|
|
27
|
+
return Uint8Array.from(out)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function ascii85Decode(data: Uint8Array): Uint8Array {
|
|
31
|
+
const out: number[] = []
|
|
32
|
+
const group: number[] = []
|
|
33
|
+
for (let i = 0; i < data.length; i++) {
|
|
34
|
+
const b = data[i]!
|
|
35
|
+
if (b === 0x7e) break // ~ (start of ~>)
|
|
36
|
+
if (b === 0x20 || b === 0x09 || b === 0x0a || b === 0x0d || b === 0x0c) continue
|
|
37
|
+
if (b === 0x7a) {
|
|
38
|
+
// 'z' — only valid between groups
|
|
39
|
+
if (group.length !== 0) throw new Error('Unexpected z in ASCII85 group')
|
|
40
|
+
out.push(0, 0, 0, 0)
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
group.push(b - 0x21)
|
|
44
|
+
if (group.length === 5) {
|
|
45
|
+
let word = 0
|
|
46
|
+
for (const g of group) word = word * 85 + g
|
|
47
|
+
out.push((word >>> 24) & 0xff, (word >>> 16) & 0xff, (word >>> 8) & 0xff, word & 0xff)
|
|
48
|
+
group.length = 0
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (group.length === 1) throw new Error('Truncated ASCII85 group')
|
|
52
|
+
if (group.length > 1) {
|
|
53
|
+
const n = group.length
|
|
54
|
+
while (group.length < 5) group.push(84)
|
|
55
|
+
let word = 0
|
|
56
|
+
for (const g of group) word = word * 85 + g
|
|
57
|
+
const bytes = [(word >>> 24) & 0xff, (word >>> 16) & 0xff, (word >>> 8) & 0xff, word & 0xff]
|
|
58
|
+
for (let k = 0; k < n - 1; k++) out.push(bytes[k]!)
|
|
59
|
+
}
|
|
60
|
+
return Uint8Array.from(out)
|
|
61
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ASCIIHexDecode (spec §7.1). Available; not used by default. Each byte → two
|
|
3
|
+
* uppercase hex digits, terminated by the `>` EOD marker.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const HEX = '0123456789ABCDEF'
|
|
7
|
+
|
|
8
|
+
export function asciiHexEncode(data: Uint8Array): Uint8Array {
|
|
9
|
+
const out = new Uint8Array(data.length * 2 + 1)
|
|
10
|
+
let i = 0
|
|
11
|
+
for (const b of data) {
|
|
12
|
+
out[i++] = HEX.charCodeAt(b >> 4)
|
|
13
|
+
out[i++] = HEX.charCodeAt(b & 0x0f)
|
|
14
|
+
}
|
|
15
|
+
out[i] = 0x3e // >
|
|
16
|
+
return out
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function asciiHexDecode(data: Uint8Array): Uint8Array {
|
|
20
|
+
const nibbles: number[] = []
|
|
21
|
+
for (const b of data) {
|
|
22
|
+
if (b === 0x3e) break // > EOD
|
|
23
|
+
if (b === 0x20 || b === 0x09 || b === 0x0a || b === 0x0d || b === 0x0c) continue
|
|
24
|
+
const c = String.fromCharCode(b)
|
|
25
|
+
const v = parseInt(c, 16)
|
|
26
|
+
if (Number.isNaN(v)) throw new Error(`Invalid ASCIIHex digit: ${c}`)
|
|
27
|
+
nibbles.push(v)
|
|
28
|
+
}
|
|
29
|
+
if (nibbles.length % 2 === 1) nibbles.push(0) // odd → last nibble is 0
|
|
30
|
+
const out = new Uint8Array(nibbles.length / 2)
|
|
31
|
+
for (let j = 0; j < out.length; j++) out[j] = (nibbles[2 * j]! << 4) | nibbles[2 * j + 1]!
|
|
32
|
+
return out
|
|
33
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FlateDecode (spec §7.2). Node/Bun build only — `node:zlib` at level 9 for
|
|
3
|
+
* deterministic output (level affects the byte sequence). No browser fallback
|
|
4
|
+
* (project decision); no PNG/TIFF predictor in v1.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { deflateSync, inflateSync } from 'node:zlib'
|
|
8
|
+
|
|
9
|
+
/** Deflate (zlib) encode at level 9. */
|
|
10
|
+
export function flateEncode(data: Uint8Array): Uint8Array {
|
|
11
|
+
return new Uint8Array(deflateSync(data, { level: 9 }))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Inflate — not used in production output; provided for round-trip tests. */
|
|
15
|
+
export function flateDecode(data: Uint8Array): Uint8Array {
|
|
16
|
+
return new Uint8Array(inflateSync(data))
|
|
17
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export {
|
|
2
|
+
makeStream,
|
|
3
|
+
makeContentStream,
|
|
4
|
+
MIN_FILTER_BYTES,
|
|
5
|
+
} from './stream.ts'
|
|
6
|
+
export type { FilterName, MakeStreamOptions } from './stream.ts'
|
|
7
|
+
export { flateEncode, flateDecode } from './flate.ts'
|
|
8
|
+
export { ascii85Encode, ascii85Decode } from './ascii85.ts'
|
|
9
|
+
export { asciiHexEncode, asciiHexDecode } from './ascii_hex.ts'
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stream construction (spec §7). Filters are applied *here*, at construction
|
|
3
|
+
* time: the resulting {@link PdfStream}'s `data` is always the final on-disk
|
|
4
|
+
* bytes and `/Length` is its filtered length. The library never stores
|
|
5
|
+
* unfiltered data alongside a filter chain.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PdfObject, PdfStream } from '../objects/types.ts'
|
|
9
|
+
import { dict, name, num } from '../objects/types.ts'
|
|
10
|
+
import { flateEncode } from './flate.ts'
|
|
11
|
+
import { ascii85Encode } from './ascii85.ts'
|
|
12
|
+
import { asciiHexEncode } from './ascii_hex.ts'
|
|
13
|
+
|
|
14
|
+
export type FilterName = 'FlateDecode' | 'ASCII85Decode' | 'ASCIIHexDecode'
|
|
15
|
+
|
|
16
|
+
/** Streams below this size aren't worth filtering (spec §7.3). */
|
|
17
|
+
export const MIN_FILTER_BYTES = 64
|
|
18
|
+
|
|
19
|
+
export interface MakeStreamOptions {
|
|
20
|
+
/**
|
|
21
|
+
* `'auto'` (default) FlateDecodes when worthwhile; `'none'` never filters;
|
|
22
|
+
* or force a specific filter.
|
|
23
|
+
*/
|
|
24
|
+
filter?: 'auto' | 'none' | FilterName
|
|
25
|
+
/** Data is already compressed (e.g. JPEG/DCT) — never re-filter (§7.3). */
|
|
26
|
+
alreadyCompressed?: boolean
|
|
27
|
+
/** Extra dictionary entries (e.g. /Type, /Subtype for XObjects). */
|
|
28
|
+
extra?: Record<string, PdfObject>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function applyFilter(data: Uint8Array, f: FilterName): Uint8Array {
|
|
32
|
+
switch (f) {
|
|
33
|
+
case 'FlateDecode':
|
|
34
|
+
return flateEncode(data)
|
|
35
|
+
case 'ASCII85Decode':
|
|
36
|
+
return ascii85Encode(data)
|
|
37
|
+
case 'ASCIIHexDecode':
|
|
38
|
+
return asciiHexEncode(data)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function makeStream(raw: Uint8Array, opts: MakeStreamOptions = {}): PdfStream {
|
|
43
|
+
const mode = opts.filter ?? 'auto'
|
|
44
|
+
|
|
45
|
+
let filter: FilterName | null
|
|
46
|
+
if (mode === 'none' || opts.alreadyCompressed) {
|
|
47
|
+
filter = null
|
|
48
|
+
} else if (mode === 'auto') {
|
|
49
|
+
filter = raw.length >= MIN_FILTER_BYTES ? 'FlateDecode' : null
|
|
50
|
+
} else {
|
|
51
|
+
filter = mode
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const data = filter ? applyFilter(raw, filter) : raw
|
|
55
|
+
|
|
56
|
+
const entries: Record<string, PdfObject> = { ...opts.extra }
|
|
57
|
+
if (filter) entries.Filter = name(filter)
|
|
58
|
+
entries.Length = num(data.length)
|
|
59
|
+
|
|
60
|
+
return { kind: 'stream', dict: dict(entries), data }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Page/Form content stream: auto-compressed (spec §7.1 default). */
|
|
64
|
+
export function makeContentStream(raw: Uint8Array): PdfStream {
|
|
65
|
+
return makeStream(raw, { filter: 'auto' })
|
|
66
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ASCII byte helpers. PDF tokens are ASCII; we work at the byte level to keep
|
|
3
|
+
* serialization deterministic and free of platform text-encoding surprises.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const encoder = new TextEncoder()
|
|
7
|
+
|
|
8
|
+
/** Encode an ASCII/Latin-1 string to bytes. Throws on non-Latin-1 input. */
|
|
9
|
+
export function ascii(s: string): Uint8Array {
|
|
10
|
+
const out = new Uint8Array(s.length)
|
|
11
|
+
for (let i = 0; i < s.length; i++) {
|
|
12
|
+
const c = s.charCodeAt(i)
|
|
13
|
+
if (c > 0xff) {
|
|
14
|
+
throw new Error(`Non-Latin-1 character at index ${i}: U+${c.toString(16)}`)
|
|
15
|
+
}
|
|
16
|
+
out[i] = c
|
|
17
|
+
}
|
|
18
|
+
return out
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Encode a string as UTF-8 bytes (used for XMP, ToUnicode CMap text, etc.). */
|
|
22
|
+
export function utf8(s: string): Uint8Array {
|
|
23
|
+
return encoder.encode(s)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Concatenate byte chunks into a single Uint8Array. */
|
|
27
|
+
export function concatBytes(chunks: readonly Uint8Array[]): Uint8Array {
|
|
28
|
+
let total = 0
|
|
29
|
+
for (const c of chunks) total += c.length
|
|
30
|
+
const out = new Uint8Array(total)
|
|
31
|
+
let offset = 0
|
|
32
|
+
for (const c of chunks) {
|
|
33
|
+
out.set(c, offset)
|
|
34
|
+
offset += c.length
|
|
35
|
+
}
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Common single bytes used by the encoder.
|
|
40
|
+
export const SPACE = 0x20
|
|
41
|
+
export const LF = 0x0a
|
|
42
|
+
export const CR = 0x0d
|
|
43
|
+
|
|
44
|
+
/** True for PDF whitespace bytes (ISO 32000-1 Table 1). */
|
|
45
|
+
export function isWhitespace(b: number): boolean {
|
|
46
|
+
return b === 0x00 || b === 0x09 || b === 0x0a || b === 0x0c || b === 0x0d || b === 0x20
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** True for PDF delimiter bytes (ISO 32000-1 Table 2). */
|
|
50
|
+
export function isDelimiter(b: number): boolean {
|
|
51
|
+
return (
|
|
52
|
+
b === 0x28 || // (
|
|
53
|
+
b === 0x29 || // )
|
|
54
|
+
b === 0x3c || // <
|
|
55
|
+
b === 0x3e || // >
|
|
56
|
+
b === 0x5b || // [
|
|
57
|
+
b === 0x5d || // ]
|
|
58
|
+
b === 0x7b || // {
|
|
59
|
+
b === 0x7d || // }
|
|
60
|
+
b === 0x2f || // /
|
|
61
|
+
b === 0x25 // %
|
|
62
|
+
)
|
|
63
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Big-endian binary readers and fixed-point helpers.
|
|
3
|
+
*
|
|
4
|
+
* Minimal in M1–M3 (only needed by document ID hashing helpers and future
|
|
5
|
+
* font/image parsers). SFNT/JPEG/PNG parsing in later milestones builds on
|
|
6
|
+
* this without restructuring.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export class BinaryReader {
|
|
10
|
+
private readonly view: DataView
|
|
11
|
+
private offset = 0
|
|
12
|
+
|
|
13
|
+
constructor(
|
|
14
|
+
private readonly bytes: Uint8Array,
|
|
15
|
+
offset = 0
|
|
16
|
+
) {
|
|
17
|
+
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
18
|
+
this.offset = offset
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get position(): number {
|
|
22
|
+
return this.offset
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
seek(offset: number): void {
|
|
26
|
+
this.offset = offset
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
u8(): number {
|
|
30
|
+
return this.view.getUint8(this.offset++)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
u16(): number {
|
|
34
|
+
const v = this.view.getUint16(this.offset, false)
|
|
35
|
+
this.offset += 2
|
|
36
|
+
return v
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
i16(): number {
|
|
40
|
+
const v = this.view.getInt16(this.offset, false)
|
|
41
|
+
this.offset += 2
|
|
42
|
+
return v
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
u32(): number {
|
|
46
|
+
const v = this.view.getUint32(this.offset, false)
|
|
47
|
+
this.offset += 4
|
|
48
|
+
return v
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Read `n` raw bytes as a subarray (no copy). */
|
|
52
|
+
bytesOf(n: number): Uint8Array {
|
|
53
|
+
const out = this.bytes.subarray(this.offset, this.offset + n)
|
|
54
|
+
this.offset += n
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get remaining(): number {
|
|
59
|
+
return this.bytes.length - this.offset
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Convert a signed 16.16 fixed-point value to a float. */
|
|
64
|
+
export function fixed1616(raw: number): number {
|
|
65
|
+
return raw / 65536
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Convert a signed 2.14 fixed-point value (F2Dot14) to a float. */
|
|
69
|
+
export function f2dot14(raw: number): number {
|
|
70
|
+
return (raw << 16) / (1 << 30)
|
|
71
|
+
}
|