pdf-codec 1.9.0 → 1.10.0
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 +3 -1
- package/dist/codec.d.cts +12 -0
- package/dist/codec.d.ts +12 -0
- package/dist/content-write.cjs +151 -0
- package/dist/content-write.js +151 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -175,7 +175,7 @@ The package is layered from generic primitives outward to the codec itself:
|
|
|
175
175
|
- **`src/util/`** — two small, independently-duplicated copies of logic that lives elsewhere in the family for a reason narrow enough not to warrant a shared dependency: `base64.ts` (isomorphic base64 ⇄ `Uint8Array`, a verbatim copy of `odf.js`'s own `src/util/base64.ts`, replacing a dependency this codec used to have on `ooxml.js` purely for this one helper pair) and `abort.ts` (`throwIfAborted`, a four-line signal-check helper called at every page loop boundary in `write.ts`/`read.ts` — there is no `await` point in this package's synchronous reader/writer pipeline for cancellation to hook into implicitly, so every long-running loop checks explicitly instead; a duplicate of `documents.js`'s own `src/ports/abort.ts`, which stays there since other, non-PDF consumers still depend on it in that repository).
|
|
176
176
|
- **`src/crypto/`** — MD5, SHA-256/384/512, RC4, and AES-CBC, hand-written with zero local imports, exactly like `src/bytes/`. Not a preference: ISO 32000-1's own key-derivation algorithms name MD5 and RC4 directly, neither of which any platform crypto API this package could portably reach still offers, and `crypto.subtle` is asynchronous where this codec's read path is synchronous end to end. Reaching for `node:crypto` would put a Node builtin inside a `src/` tree that deliberately has none and is built with `platform: 'neutral'`, breaking the browser bundle its downstream consumer depends on. Each module cites the specification it implements (RFC 1321, FIPS 180-4, FIPS 197) and is tested against that specification's own published conformance vectors. MD5 and RC4 are cryptographically broken and are here solely to *read* files that already exist and whose format mandates them.
|
|
177
177
|
- **The codec itself, importing only `math-types`/`formula`/`bytes`/`image`/`crypto` (no OOXML or ODF knowledge at all):**
|
|
178
|
-
- **Write**: `objects.ts` (the `PdfObject` discriminated union), `afm-widths.ts`/`encoding.ts`/`winansi.ts`/`fonts.ts` (standard-14 metrics, WinAnsi encoding, family resolution), `font-registry.ts` (the source-document → caller-supplied → vendored-substitute → standard-14 resolution port sitting in front of `resolveStandardFont`, plus `resolveFaceWithRegistry`, the one step both the measurer and the writer resolve through so they can never disagree about which face a `LayoutFont` means), `measure.ts`/`text-layout.ts` (greedy line-wrapping, measured against either a standard-14 face's AFM widths plus a per-family correction or a resolved face's own real `hmtx` advances — never both, see [Fidelity](#fidelity)), `matrix.ts`, `content-write.ts` (`LayoutItem[]` → content-stream operators, with text branching on whether its font resolved to a standard-14 face shown as a WinAnsi byte string with `Tj` or an embedded one shown as Identity-H 2-byte CIDs, split at that face's own pair-kerning adjustments into a `TJ` array where it has any to apply and shown as a single unsplit `Tj` string where it does not), `write.ts` (the full object graph, classic cross-reference table, trailer, and — when `WritePdfOptions.formulas` is non-empty — one embedded math composite font group, plus one embedded text font group per face `WritePdfOptions.fonts` resolved, each allocated once in a fixed sorted order and shared across pages).
|
|
178
|
+
- **Write**: `objects.ts` (the `PdfObject` discriminated union), `afm-widths.ts`/`encoding.ts`/`winansi.ts`/`fonts.ts` (standard-14 metrics, WinAnsi encoding, family resolution), `font-registry.ts` (the source-document → caller-supplied → vendored-substitute → standard-14 resolution port sitting in front of `resolveStandardFont`, plus `resolveFaceWithRegistry`, the one step both the measurer and the writer resolve through so they can never disagree about which face a `LayoutFont` means), `measure.ts`/`text-layout.ts` (greedy line-wrapping, measured against either a standard-14 face's AFM widths plus a per-family correction or a resolved face's own real `hmtx` advances — never both, see [Fidelity](#fidelity)), `matrix.ts`, `content-write.ts` (`LayoutItem[]` → content-stream operators, with text branching on whether its font resolved to a standard-14 face shown as a WinAnsi byte string with `Tj` or an embedded one shown as Identity-H 2-byte CIDs, split at that face's own pair-kerning adjustments into a `TJ` array where it has any to apply and shown as a single unsplit `Tj` string where it does not, and with a stroked line's or path's own `style` becoming either a real dash-array/line-cap state pair around the paint operator or, for `double`, two perpendicular-offset copies of the geometry — see [Gotchas](#gotchas-and-quirks)), `write.ts` (the full object graph, classic cross-reference table, trailer, and — when `WritePdfOptions.formulas` is non-empty — one embedded math composite font group, plus one embedded text font group per face `WritePdfOptions.fonts` resolved, each allocated once in a fixed sorted order and shared across pages).
|
|
179
179
|
- **sfnt font tables**: `sfnt.ts` (a bounds-checked sfnt table-directory reader, big-endian primitive readers, and the `hasBytes` range check every table parser below pre-checks with), `cmap-table.ts` (Unicode → glyph ID, formats 4/12/6), `hmtx-table.ts` (per-glyph advance widths), `font-tables.ts` (`head`/`maxp`/`OS/2`/`post`/`name` — design grid, glyph count, vertical metrics and style bits, italic angle and underline geometry, PostScript and family names), `glyf.ts` (the `loca` offset index, per-glyph headers, a composite glyph's own component records — a composite refers to its base letter and combining marks by glyph ID, and those references nest, so subsetting one safely means taking the transitive closure over this walk — and `glyphInkBounds`, a glyph's own tight ink box, read straight out of a simple glyph's header where the format already states it and unioned from a composite's own transformed, placed components where it does not), and `math-table.ts` (the OpenType `MATH` table's constants, glyph-info, and variants subtables). `ot-layout-common.ts` holds the two Common Table Formats every OpenType Layout table indexes glyphs through — Coverage and ClassDef — stored as sorted glyph ranges and searched by bisection rather than expanded into a glyph-keyed map, since six bytes of a format 2 record can legitimately declare a 65536-glyph range and expanding every range of every subtable turns a small untrusted font into a large allocation. `gpos-table.ts` reads a face's own `GPOS` table for exactly one thing: how much the font wants the advance of glyph A adjusted when glyph B follows it. It resolves the `kern` feature through the ScriptList (rather than sweeping the FeatureList for every feature tagged `kern`, which is what would apply a font's Cyrillic or Greek kerning lookups to Latin text), handles both PairPos subtable formats and the LookupType 9 Extension indirection — all three are real code paths, since Carlito reaches its kerning only through Extension-wrapped format 2 subtables while Caladea uses LookupType 2 directly and mixes formats inside one lookup — and reads only the first glyph's XAdvance, the one field a horizontal left-to-right run's next glyph position can depend on. Mark attachment, cursive joining, and contextual positioning have no consumer in a codec that positions glyphs itself, so they are not parsed. Every one of these parsers degrades to `undefined` on a missing or truncated table rather than throwing: the vendored fonts are trusted, but a font extracted from an arbitrary source document is not.
|
|
180
180
|
- **sfnt subsetting**: `sfnt-subset.ts` (a TrueType-outline glyph subsetter: Unicode code points → glyph IDs through `cmap-table.ts`, the transitive closure over `glyf.ts`'s composite-component walk, then a rebuilt sfnt container carrying only those glyphs' outlines). **Glyph IDs are preserved, never renumbered** — an unused ID below the highest used one survives as an empty `loca` entry rather than being squeezed out — which keeps every composite's own component references correct inside bytes copied verbatim, keeps a caller's already-resolved glyph IDs valid against the subset, and makes CID == GID trivially true for the embedded program (a `/CIDFontType2` then needs only `/CIDToGIDMap /Identity`). The output rebuilds `head`/`hhea`/`maxp`/`loca`/`glyf`/`hmtx` (with `indexToLocFormat` forced long, one always-legal code path), copies the hinting programs (`cvt `/`fpgm`/`prep`) verbatim, stubs `post` as a version 3.0 "no glyph names" header, and omits `cmap`/`name`/`OS/2`/`GSUB`/`GPOS`/`kern` — none of which an embedded `CIDFontType2` program is read through (ISO 32000-1 9.9). Dropping `GPOS` costs the document no kerning even though its pairs are now genuinely applied: the adjustments are resolved at write time and written into the page's own `TJ` array, so a consumer reads them off the content stream rather than out of the font program. The honest cost of preserving IDs: `loca` and `hmtx` stay proportional to the highest used glyph ID rather than to the number of glyphs kept, so for a document touching one glyph near the end of a large font's glyph order, most of the (already much smaller) output is those two index tables rather than outline data. Applies to `glyf`-flavoured fonts only; a CFF-flavoured one returns `undefined`, the same scope boundary [Fidelity](#fidelity) states for the embedded math font.
|
|
181
181
|
- **Embedded math font**: `math-font.ts` (parses and caches the vendored STIX Two Math font once per process, exposing a size-specific `MathFontMetrics` implementation and a points-in/points-out stretchy-glyph entry point), `math-stretch.ts` (the OpenType MATH two-stage stretching model over that parsed `MathVariants` data: pick the smallest pre-built variant that reaches the target, else assemble from repeated parts with every seam overlapped inside both sides' own declared connector lengths -- unit-agnostic, so it works in design units or points alike), `math-font-write.ts` (builds the `/Type0`/`/CIDFontType0`/`/FontDescriptor`/`/FontFile3`/ToUnicode object group), `math-content-write.ts` (a `PositionedFormula[]` → PDF content-stream bytes, Identity-H 2-byte CIDs for text-showing, `re`/`m`/`l` operators for rules and the radical hook, and one glyph-ID-addressed text object per placement of a stretched construction, wrapped in an `/ActualText` marked-content span). See [Fidelity](#fidelity) for the CFF-full-embed (not glyph-subsetted) simplification this makes.
|
|
@@ -215,6 +215,8 @@ Dependency direction is strictly downward and checkable: `math-types`/`formula`/
|
|
|
215
215
|
- **Real per-glyph ink bounds are now measured from the outline, but `ascentPerEm`/`descentPerEm` still exist alongside them and a caller has to choose.** `MathGlyphMetrics.inkAscentPt`/`inkDescentPt` (and `MathFont.glyphInkBounds`, the same thing in design units) carry each glyph's own tight ink extent, computed by walking its Type 2 charstring — a full stop measures 0.12 em tall against the 1.0 em the font's nominal `hhea` metrics claim for every glyph alike. `ascentPerEm`/`descentPerEm` remain what they always were: one uniform figure for the whole face, still the right measure for anything sized against the font rather than against particular characters, and still the fallback for a glyph with no outline to measure (a space) or one `cff-bounds.ts` declines to walk, where both ink fields come back `undefined` together. The consumer that motivated this — `documents.js`'s own MathML `layoutToken` — now uses them: it takes the max ink ascent and max ink descent across a run's glyphs, falling back to the nominal metrics per glyph that carries no bounds.
|
|
216
216
|
- **An ink box is genuinely tight, which for a math font means it is often *larger* than the nominal metrics, not smaller.** The "ink is a fraction of the nominal extent" intuition holds for text-like glyphs (a full stop, a parenthesis, an `x`) and fails for the extension pieces, display-size operators, and pre-built large variants a math font is full of: over a tenth of STIX Two Math's repertoire draws above its own nominal ascent, and another tenth below its nominal descent, reaching 2.6 em up and 1.6 em down at the extremes. Sizing those from `ascentPerEm` under-reports them exactly as badly as it over-reports a full stop, which is the whole reason the per-glyph measurement exists.
|
|
217
217
|
- **A glyph's ink descent is negative where its lowest ink sits above the baseline.** `inkDescentPt` follows `descentPerEm`'s own sign convention (ink below the baseline is a positive descent), so a superscript-height glyph honestly reports a negative descent rather than a clamped zero. A consumer that needs a box which never crosses the baseline clamps at its own layer, where it can see what the box is for.
|
|
218
|
+
- **A `LayoutLine`/`LayoutPath` `style` of `dashed` or `dotted` becomes a real dash-array (`d`) operator scaled to that stroke's own width, and `double` becomes two genuinely separate offset strokes.** Dash lengths are multiples of the stroke width rather than fixed point lengths, so a hairline rule and a thick one both read as recognisably dashed: `dashed` emits `[3w 3w] 0 d`, `dotted` emits `[0 2w] 0 d` together with a `1 J` round cap. That zero on-length is deliberate and load-bearing — a zero-length dash under a round cap paints its two caps over the same point, i.e. one filled circle of diameter `w`, which is exactly a dot, whereas any non-zero on-length paints a capsule that reads as a short dash. Under PDF's DEFAULT butt cap the identical array paints nothing at all, which is why the `J` operator is not decoration here. Both are reset (`[] 0 d`, and `0 J` after a dotted stroke) immediately after the paint operator: the graphics state persists for the whole content stream, so a dashed table rule left un-reset would silently dash every later line, rect, ellipse, path, and text underline on the same page. `double` has no PDF operator at all and is drawn as geometry instead — the declared width `w` splits into three equal bands (ink, gap, ink), so each rule is `w/3` wide with its centreline `w/3` either side of the original, putting the pair's outer edges exactly where the single solid stroke's own edges would have been. A `LayoutPath`'s two offset copies move each ON-PATH point along the bisector of its two adjacent chord normals (a closed subpath's implicit `h` edge included, since that is real ink) and each cubic control point along its own segment's chord normal — a chord-based approximation of a true parallel curve, which for a cubic is not itself a cubic and cannot be written as one, but at an offset of a third of a stroke width the difference sits far below the width of the ink being drawn. A `double` path's fill, if it has one, paints once from the original un-offset geometry: doubling describes the rule drawn along the path, not the region it encloses. Two boundaries worth stating outright: a zero-length `double` line has no direction to be perpendicular to and falls back to a single stroke at its declared width, and `LayoutRect`/`LayoutEllipse` carry no `style` field at all in document-schema.js, so there is nothing to apply to them.
|
|
219
|
+
- **Nothing on the read side recovers a stroke style.** `interpret.ts` ignores `d` and `J` along with every other graphics-state operator outside its extraction scope, so a dashed line read back through `readPdf` comes back solid, and a `double` one comes back as the two separate strokes it genuinely is in the file. This is the same asymmetry the rest of the writer already has (see the general-vector-path gotcha below): PDF records what was painted, not the authoring intent behind it, and inferring "these two parallel strokes were one double rule" from geometry would be reconstruction guesswork of a kind nothing else in this parser does.
|
|
218
220
|
- **An embedded `CIDFontType2` program needs no `cmap` table of its own, and `sfnt-subset.ts`'s output doesn't carry one — this is a property of the spec, not an oversight this package works around.** `cmap` maps a character code to a glyph ID for a *simple* font; a `Type0` composite font never asks the embedded font program to do that lookup at all. Character code → CID goes through the `Type0` font's own `/Encoding` (Identity-H here, so CID == character code by construction for the 2-byte codes this package writes), and CID → GID goes through `/CIDToGIDMap` (`/Identity` here, matching `sfnt-subset.ts`'s own GID-preserving design). Both steps happen inside the PDF's own object graph, entirely before the embedded font program is ever consulted — ISO 32000-1 9.7.4.2. This is exactly why the subset output can safely omit `cmap` alongside `name`/`OS/2`/`GSUB`/`GPOS`/`kern` (see Architecture above): none of the five is on the code-path a `CIDFontType2` reader actually walks.
|
|
219
221
|
- **`GPOS` pair kerning is read and applied for an embedded face; no other OpenType layout feature is.** An embedded run's glyphs are placed at their own `hmtx` advances adjusted by the font's own pair kerning, written into the page as a real `TJ` array (see the gotcha below). `GSUB` is a different matter and is genuinely not supported: ligature substitution, contextual alternates, and small caps are never applied, so a face's `fi` ligature is drawn as two separate glyphs. The legacy `kern` table is not read either, and nothing is lost by that for the vendored families — neither Carlito nor Caladea ships one in any face; both carry all of their real pair kerning in `GPOS`.
|
|
220
222
|
- **A kerned run is shown with `TJ`, and the sign of a `TJ` number is the opposite of the adjustment it expresses.** ISO 32000-1 9.4.3 defines a number in a `TJ` array as being SUBTRACTED from the current horizontal coordinate, in thousandths of a unit of text space — so a positive number moves the next glyph closer, and a pair the font tightens by 43.457 glyph-space units is written as `+43.457`, not `-43.457`. `content-write.ts` negates the advance delta at exactly that one point, and `interpret.ts`'s own `TJ` handling is the reader half of the same convention, so a page written here and read back through this package's own parser recovers the positions it was written with (`write-embedded-font.test.ts` asserts that round trip specifically, since it is what settles the direction empirically rather than by argument from the specification alone). A run whose adjacent pairs the face kerns nothing about is still shown as one unsplit hex string with `Tj`, byte for byte what this package emitted before kerning existed — only a genuinely kerned run pays for an array.
|
package/dist/codec.d.cts
CHANGED
|
@@ -83,6 +83,12 @@ declare const pdfCodec: z.ZodCodec<z.ZodCustom<Uint8Array<ArrayBuffer>, Uint8Arr
|
|
|
83
83
|
b: z.ZodNumber;
|
|
84
84
|
}, z.core.$strip>;
|
|
85
85
|
widthPt: z.ZodNumber;
|
|
86
|
+
style: z.ZodOptional<z.ZodEnum<{
|
|
87
|
+
solid: "solid";
|
|
88
|
+
dashed: "dashed";
|
|
89
|
+
dotted: "dotted";
|
|
90
|
+
double: "double";
|
|
91
|
+
}>>;
|
|
86
92
|
sourcePath: z.ZodOptional<z.ZodString>;
|
|
87
93
|
}, z.core.$strip>, z.ZodObject<{
|
|
88
94
|
kind: z.ZodLiteral<"ellipse">;
|
|
@@ -141,6 +147,12 @@ declare const pdfCodec: z.ZodCodec<z.ZodCustom<Uint8Array<ArrayBuffer>, Uint8Arr
|
|
|
141
147
|
}, z.core.$strip>;
|
|
142
148
|
widthPt: z.ZodNumber;
|
|
143
149
|
}, z.core.$strip>>;
|
|
150
|
+
style: z.ZodOptional<z.ZodEnum<{
|
|
151
|
+
solid: "solid";
|
|
152
|
+
dashed: "dashed";
|
|
153
|
+
dotted: "dotted";
|
|
154
|
+
double: "double";
|
|
155
|
+
}>>;
|
|
144
156
|
sourcePath: z.ZodOptional<z.ZodString>;
|
|
145
157
|
}, z.core.$strip>, z.ZodObject<{
|
|
146
158
|
kind: z.ZodLiteral<"link">;
|
package/dist/codec.d.ts
CHANGED
|
@@ -83,6 +83,12 @@ declare const pdfCodec: z.ZodCodec<z.ZodCustom<Uint8Array<ArrayBuffer>, Uint8Arr
|
|
|
83
83
|
b: z.ZodNumber;
|
|
84
84
|
}, z.core.$strip>;
|
|
85
85
|
widthPt: z.ZodNumber;
|
|
86
|
+
style: z.ZodOptional<z.ZodEnum<{
|
|
87
|
+
solid: "solid";
|
|
88
|
+
dashed: "dashed";
|
|
89
|
+
dotted: "dotted";
|
|
90
|
+
double: "double";
|
|
91
|
+
}>>;
|
|
86
92
|
sourcePath: z.ZodOptional<z.ZodString>;
|
|
87
93
|
}, z.core.$strip>, z.ZodObject<{
|
|
88
94
|
kind: z.ZodLiteral<"ellipse">;
|
|
@@ -141,6 +147,12 @@ declare const pdfCodec: z.ZodCodec<z.ZodCustom<Uint8Array<ArrayBuffer>, Uint8Arr
|
|
|
141
147
|
}, z.core.$strip>;
|
|
142
148
|
widthPt: z.ZodNumber;
|
|
143
149
|
}, z.core.$strip>>;
|
|
150
|
+
style: z.ZodOptional<z.ZodEnum<{
|
|
151
|
+
solid: "solid";
|
|
152
|
+
dashed: "dashed";
|
|
153
|
+
dotted: "dotted";
|
|
154
|
+
double: "double";
|
|
155
|
+
}>>;
|
|
144
156
|
sourcePath: z.ZodOptional<z.ZodString>;
|
|
145
157
|
}, z.core.$strip>, z.ZodObject<{
|
|
146
158
|
kind: z.ZodLiteral<"link">;
|
package/dist/content-write.cjs
CHANGED
|
@@ -97,6 +97,105 @@ function paintOperatorFor(fill, stroke, fillRule) {
|
|
|
97
97
|
function formatPoint(x, y) {
|
|
98
98
|
return `${require_serialize.formatNumber(x)} ${require_serialize.formatNumber(y)}`;
|
|
99
99
|
}
|
|
100
|
+
const DASHED_ON_WIDTH_MULTIPLE = 3;
|
|
101
|
+
const DASHED_GAP_WIDTH_MULTIPLE = 3;
|
|
102
|
+
const DOTTED_ON_LENGTH_PT = 0;
|
|
103
|
+
const DOTTED_GAP_WIDTH_MULTIPLE = 2;
|
|
104
|
+
const DASH_PHASE_PT = 0;
|
|
105
|
+
const LINE_CAP_BUTT = 0;
|
|
106
|
+
const LINE_CAP_ROUND = 1;
|
|
107
|
+
const DOUBLE_RULE_BANDS = 3;
|
|
108
|
+
const DOUBLE_RULE_SIGNS = [1, -1];
|
|
109
|
+
function writeStrokeStyleState(writer, style, strokeWidthPt) {
|
|
110
|
+
if (style === "dashed") {
|
|
111
|
+
writer.writeAscii(`[${require_serialize.formatNumber(strokeWidthPt * DASHED_ON_WIDTH_MULTIPLE)} ${require_serialize.formatNumber(strokeWidthPt * DASHED_GAP_WIDTH_MULTIPLE)}] ${require_serialize.formatNumber(DASH_PHASE_PT)} d\n`);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
if (style === "dotted") {
|
|
115
|
+
writer.writeAscii(`[${require_serialize.formatNumber(DOTTED_ON_LENGTH_PT)} ${require_serialize.formatNumber(strokeWidthPt * DOTTED_GAP_WIDTH_MULTIPLE)}] ${require_serialize.formatNumber(DASH_PHASE_PT)} d\n`);
|
|
116
|
+
writer.writeAscii(`${require_serialize.formatNumber(LINE_CAP_ROUND)} J\n`);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
function resetStrokeStyleState(writer, style) {
|
|
122
|
+
writer.writeAscii(`[] ${require_serialize.formatNumber(DASH_PHASE_PT)} d\n`);
|
|
123
|
+
if (style === "dotted") writer.writeAscii(`${require_serialize.formatNumber(LINE_CAP_BUTT)} J\n`);
|
|
124
|
+
}
|
|
125
|
+
function chordNormal(fromX, fromY, toX, toY) {
|
|
126
|
+
const dx = toX - fromX;
|
|
127
|
+
const dy = toY - fromY;
|
|
128
|
+
const length = Math.hypot(dx, dy);
|
|
129
|
+
if (length === 0) return;
|
|
130
|
+
return {
|
|
131
|
+
x: -dy / length,
|
|
132
|
+
y: dx / length
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function averageNormal(a, b) {
|
|
136
|
+
if (a === void 0) return b;
|
|
137
|
+
if (b === void 0) return a;
|
|
138
|
+
const x = a.x + b.x;
|
|
139
|
+
const y = a.y + b.y;
|
|
140
|
+
const length = Math.hypot(x, y);
|
|
141
|
+
if (length === 0) return;
|
|
142
|
+
return {
|
|
143
|
+
x: x / length,
|
|
144
|
+
y: y / length
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function offsetX(x, normal, offsetPt) {
|
|
148
|
+
return normal === void 0 ? x : x + normal.x * offsetPt;
|
|
149
|
+
}
|
|
150
|
+
function offsetY(y, normal, offsetPt) {
|
|
151
|
+
return normal === void 0 ? y : y + normal.y * offsetPt;
|
|
152
|
+
}
|
|
153
|
+
function offsetSubpath(subpath, offsetPt) {
|
|
154
|
+
const points = [{
|
|
155
|
+
x: subpath.startXPt,
|
|
156
|
+
y: subpath.startYPt
|
|
157
|
+
}, ...subpath.segments.map((segment) => ({
|
|
158
|
+
x: segment.xPt,
|
|
159
|
+
y: segment.yPt
|
|
160
|
+
}))];
|
|
161
|
+
const chords = [];
|
|
162
|
+
for (let i = 0; i + 1 < points.length; i += 1) {
|
|
163
|
+
const from = points[i];
|
|
164
|
+
const to = points[i + 1];
|
|
165
|
+
chords.push(chordNormal(from.x, from.y, to.x, to.y));
|
|
166
|
+
}
|
|
167
|
+
if (subpath.closed && points.length > 1) {
|
|
168
|
+
const from = points[points.length - 1];
|
|
169
|
+
const to = points[0];
|
|
170
|
+
chords.push(chordNormal(from.x, from.y, to.x, to.y));
|
|
171
|
+
}
|
|
172
|
+
const vertexNormals = points.map((_point, i) => {
|
|
173
|
+
return averageNormal(i === 0 ? subpath.closed ? chords[chords.length - 1] : void 0 : chords[i - 1], chords[i]);
|
|
174
|
+
});
|
|
175
|
+
return {
|
|
176
|
+
startXPt: offsetX(subpath.startXPt, vertexNormals[0], offsetPt),
|
|
177
|
+
startYPt: offsetY(subpath.startYPt, vertexNormals[0], offsetPt),
|
|
178
|
+
closed: subpath.closed,
|
|
179
|
+
segments: subpath.segments.map((segment, i) => {
|
|
180
|
+
const endNormal = vertexNormals[i + 1];
|
|
181
|
+
if (segment.kind === "line") return {
|
|
182
|
+
kind: "line",
|
|
183
|
+
xPt: offsetX(segment.xPt, endNormal, offsetPt),
|
|
184
|
+
yPt: offsetY(segment.yPt, endNormal, offsetPt)
|
|
185
|
+
};
|
|
186
|
+
const controlNormal = chords[i] ?? vertexNormals[i];
|
|
187
|
+
return {
|
|
188
|
+
kind: "cubic",
|
|
189
|
+
c1xPt: offsetX(segment.c1xPt, controlNormal, offsetPt),
|
|
190
|
+
c1yPt: offsetY(segment.c1yPt, controlNormal, offsetPt),
|
|
191
|
+
c2xPt: offsetX(segment.c2xPt, controlNormal, offsetPt),
|
|
192
|
+
c2yPt: offsetY(segment.c2yPt, controlNormal, offsetPt),
|
|
193
|
+
xPt: offsetX(segment.xPt, endNormal, offsetPt),
|
|
194
|
+
yPt: offsetY(segment.yPt, endNormal, offsetPt)
|
|
195
|
+
};
|
|
196
|
+
})
|
|
197
|
+
};
|
|
198
|
+
}
|
|
100
199
|
function writeFillAndStroke(writer, fill, stroke) {
|
|
101
200
|
if (fill !== void 0) writeRgbOperator(writer, fill, "rg");
|
|
102
201
|
if (stroke !== void 0) {
|
|
@@ -111,11 +210,36 @@ function writeRect(writer, item) {
|
|
|
111
210
|
writer.writeAscii(`${require_serialize.formatNumber(item.xPt)} ${require_serialize.formatNumber(item.yPt)} ${require_serialize.formatNumber(item.widthPt)} ${require_serialize.formatNumber(item.heightPt)} re\n`);
|
|
112
211
|
writer.writeAscii(`${paint}\n`);
|
|
113
212
|
}
|
|
213
|
+
function writeDoubleLine(writer, item) {
|
|
214
|
+
writeRgbOperator(writer, item.color, "RG");
|
|
215
|
+
const normal = chordNormal(item.x1Pt, item.y1Pt, item.x2Pt, item.y2Pt);
|
|
216
|
+
if (normal === void 0) {
|
|
217
|
+
writer.writeAscii(`${require_serialize.formatNumber(item.widthPt)} w\n`);
|
|
218
|
+
writer.writeAscii(`${require_serialize.formatNumber(item.x1Pt)} ${require_serialize.formatNumber(item.y1Pt)} m ${require_serialize.formatNumber(item.x2Pt)} ${require_serialize.formatNumber(item.y2Pt)} l\n`);
|
|
219
|
+
writer.writeAscii("S\n");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const ruleWidthPt = item.widthPt / DOUBLE_RULE_BANDS;
|
|
223
|
+
const ruleOffsetPt = item.widthPt / DOUBLE_RULE_BANDS;
|
|
224
|
+
writer.writeAscii(`${require_serialize.formatNumber(ruleWidthPt)} w\n`);
|
|
225
|
+
for (const sign of DOUBLE_RULE_SIGNS) {
|
|
226
|
+
const dx = normal.x * ruleOffsetPt * sign;
|
|
227
|
+
const dy = normal.y * ruleOffsetPt * sign;
|
|
228
|
+
writer.writeAscii(`${formatPoint(item.x1Pt + dx, item.y1Pt + dy)} m ${formatPoint(item.x2Pt + dx, item.y2Pt + dy)} l\n`);
|
|
229
|
+
writer.writeAscii("S\n");
|
|
230
|
+
}
|
|
231
|
+
}
|
|
114
232
|
function writeLine(writer, item) {
|
|
233
|
+
if (item.style === "double") {
|
|
234
|
+
writeDoubleLine(writer, item);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
115
237
|
writeRgbOperator(writer, item.color, "RG");
|
|
116
238
|
writer.writeAscii(`${require_serialize.formatNumber(item.widthPt)} w\n`);
|
|
239
|
+
const styled = writeStrokeStyleState(writer, item.style, item.widthPt);
|
|
117
240
|
writer.writeAscii(`${require_serialize.formatNumber(item.x1Pt)} ${require_serialize.formatNumber(item.y1Pt)} m ${require_serialize.formatNumber(item.x2Pt)} ${require_serialize.formatNumber(item.y2Pt)} l\n`);
|
|
118
241
|
writer.writeAscii("S\n");
|
|
242
|
+
if (styled) resetStrokeStyleState(writer, item.style);
|
|
119
243
|
}
|
|
120
244
|
function writeEllipse(writer, item) {
|
|
121
245
|
const paint = paintOperatorFor(item.fill, item.stroke);
|
|
@@ -141,12 +265,39 @@ function writeSubpath(writer, subpath) {
|
|
|
141
265
|
else writer.writeAscii(`${formatPoint(segment.c1xPt, segment.c1yPt)} ${formatPoint(segment.c2xPt, segment.c2yPt)} ${formatPoint(segment.xPt, segment.yPt)} c\n`);
|
|
142
266
|
if (subpath.closed) writer.writeAscii("h\n");
|
|
143
267
|
}
|
|
268
|
+
function writeDoublePath(writer, item, stroke) {
|
|
269
|
+
if (item.fill !== void 0) {
|
|
270
|
+
writeRgbOperator(writer, item.fill, "rg");
|
|
271
|
+
for (const subpath of item.subpaths) writeSubpath(writer, subpath);
|
|
272
|
+
writer.writeAscii(`${item.fillRule === "evenodd" ? "f*" : "f"}\n`);
|
|
273
|
+
}
|
|
274
|
+
const ruleWidthPt = stroke.widthPt / DOUBLE_RULE_BANDS;
|
|
275
|
+
const ruleOffsetPt = stroke.widthPt / DOUBLE_RULE_BANDS;
|
|
276
|
+
writeRgbOperator(writer, stroke.color, "RG");
|
|
277
|
+
writer.writeAscii(`${require_serialize.formatNumber(ruleWidthPt)} w\n`);
|
|
278
|
+
for (const sign of DOUBLE_RULE_SIGNS) {
|
|
279
|
+
for (const subpath of item.subpaths) writeSubpath(writer, offsetSubpath(subpath, ruleOffsetPt * sign));
|
|
280
|
+
writer.writeAscii("S\n");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
144
283
|
function writePath(writer, item) {
|
|
145
284
|
const paint = paintOperatorFor(item.fill, item.stroke, item.fillRule);
|
|
146
285
|
if (paint === void 0) return;
|
|
286
|
+
if (item.stroke === void 0) {
|
|
287
|
+
writeFillAndStroke(writer, item.fill, void 0);
|
|
288
|
+
for (const subpath of item.subpaths) writeSubpath(writer, subpath);
|
|
289
|
+
writer.writeAscii(`${paint}\n`);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (item.style === "double") {
|
|
293
|
+
writeDoublePath(writer, item, item.stroke);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
147
296
|
writeFillAndStroke(writer, item.fill, item.stroke);
|
|
297
|
+
const styled = writeStrokeStyleState(writer, item.style, item.stroke.widthPt);
|
|
148
298
|
for (const subpath of item.subpaths) writeSubpath(writer, subpath);
|
|
149
299
|
writer.writeAscii(`${paint}\n`);
|
|
300
|
+
if (styled) resetStrokeStyleState(writer, item.style);
|
|
150
301
|
}
|
|
151
302
|
function writeImage(writer, item, context) {
|
|
152
303
|
const image = context.resolveImage(item.imageId);
|
package/dist/content-write.js
CHANGED
|
@@ -96,6 +96,105 @@ function paintOperatorFor(fill, stroke, fillRule) {
|
|
|
96
96
|
function formatPoint(x, y) {
|
|
97
97
|
return `${formatNumber(x)} ${formatNumber(y)}`;
|
|
98
98
|
}
|
|
99
|
+
const DASHED_ON_WIDTH_MULTIPLE = 3;
|
|
100
|
+
const DASHED_GAP_WIDTH_MULTIPLE = 3;
|
|
101
|
+
const DOTTED_ON_LENGTH_PT = 0;
|
|
102
|
+
const DOTTED_GAP_WIDTH_MULTIPLE = 2;
|
|
103
|
+
const DASH_PHASE_PT = 0;
|
|
104
|
+
const LINE_CAP_BUTT = 0;
|
|
105
|
+
const LINE_CAP_ROUND = 1;
|
|
106
|
+
const DOUBLE_RULE_BANDS = 3;
|
|
107
|
+
const DOUBLE_RULE_SIGNS = [1, -1];
|
|
108
|
+
function writeStrokeStyleState(writer, style, strokeWidthPt) {
|
|
109
|
+
if (style === "dashed") {
|
|
110
|
+
writer.writeAscii(`[${formatNumber(strokeWidthPt * DASHED_ON_WIDTH_MULTIPLE)} ${formatNumber(strokeWidthPt * DASHED_GAP_WIDTH_MULTIPLE)}] ${formatNumber(DASH_PHASE_PT)} d\n`);
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
if (style === "dotted") {
|
|
114
|
+
writer.writeAscii(`[${formatNumber(DOTTED_ON_LENGTH_PT)} ${formatNumber(strokeWidthPt * DOTTED_GAP_WIDTH_MULTIPLE)}] ${formatNumber(DASH_PHASE_PT)} d\n`);
|
|
115
|
+
writer.writeAscii(`${formatNumber(LINE_CAP_ROUND)} J\n`);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
function resetStrokeStyleState(writer, style) {
|
|
121
|
+
writer.writeAscii(`[] ${formatNumber(DASH_PHASE_PT)} d\n`);
|
|
122
|
+
if (style === "dotted") writer.writeAscii(`${formatNumber(LINE_CAP_BUTT)} J\n`);
|
|
123
|
+
}
|
|
124
|
+
function chordNormal(fromX, fromY, toX, toY) {
|
|
125
|
+
const dx = toX - fromX;
|
|
126
|
+
const dy = toY - fromY;
|
|
127
|
+
const length = Math.hypot(dx, dy);
|
|
128
|
+
if (length === 0) return;
|
|
129
|
+
return {
|
|
130
|
+
x: -dy / length,
|
|
131
|
+
y: dx / length
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function averageNormal(a, b) {
|
|
135
|
+
if (a === void 0) return b;
|
|
136
|
+
if (b === void 0) return a;
|
|
137
|
+
const x = a.x + b.x;
|
|
138
|
+
const y = a.y + b.y;
|
|
139
|
+
const length = Math.hypot(x, y);
|
|
140
|
+
if (length === 0) return;
|
|
141
|
+
return {
|
|
142
|
+
x: x / length,
|
|
143
|
+
y: y / length
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function offsetX(x, normal, offsetPt) {
|
|
147
|
+
return normal === void 0 ? x : x + normal.x * offsetPt;
|
|
148
|
+
}
|
|
149
|
+
function offsetY(y, normal, offsetPt) {
|
|
150
|
+
return normal === void 0 ? y : y + normal.y * offsetPt;
|
|
151
|
+
}
|
|
152
|
+
function offsetSubpath(subpath, offsetPt) {
|
|
153
|
+
const points = [{
|
|
154
|
+
x: subpath.startXPt,
|
|
155
|
+
y: subpath.startYPt
|
|
156
|
+
}, ...subpath.segments.map((segment) => ({
|
|
157
|
+
x: segment.xPt,
|
|
158
|
+
y: segment.yPt
|
|
159
|
+
}))];
|
|
160
|
+
const chords = [];
|
|
161
|
+
for (let i = 0; i + 1 < points.length; i += 1) {
|
|
162
|
+
const from = points[i];
|
|
163
|
+
const to = points[i + 1];
|
|
164
|
+
chords.push(chordNormal(from.x, from.y, to.x, to.y));
|
|
165
|
+
}
|
|
166
|
+
if (subpath.closed && points.length > 1) {
|
|
167
|
+
const from = points[points.length - 1];
|
|
168
|
+
const to = points[0];
|
|
169
|
+
chords.push(chordNormal(from.x, from.y, to.x, to.y));
|
|
170
|
+
}
|
|
171
|
+
const vertexNormals = points.map((_point, i) => {
|
|
172
|
+
return averageNormal(i === 0 ? subpath.closed ? chords[chords.length - 1] : void 0 : chords[i - 1], chords[i]);
|
|
173
|
+
});
|
|
174
|
+
return {
|
|
175
|
+
startXPt: offsetX(subpath.startXPt, vertexNormals[0], offsetPt),
|
|
176
|
+
startYPt: offsetY(subpath.startYPt, vertexNormals[0], offsetPt),
|
|
177
|
+
closed: subpath.closed,
|
|
178
|
+
segments: subpath.segments.map((segment, i) => {
|
|
179
|
+
const endNormal = vertexNormals[i + 1];
|
|
180
|
+
if (segment.kind === "line") return {
|
|
181
|
+
kind: "line",
|
|
182
|
+
xPt: offsetX(segment.xPt, endNormal, offsetPt),
|
|
183
|
+
yPt: offsetY(segment.yPt, endNormal, offsetPt)
|
|
184
|
+
};
|
|
185
|
+
const controlNormal = chords[i] ?? vertexNormals[i];
|
|
186
|
+
return {
|
|
187
|
+
kind: "cubic",
|
|
188
|
+
c1xPt: offsetX(segment.c1xPt, controlNormal, offsetPt),
|
|
189
|
+
c1yPt: offsetY(segment.c1yPt, controlNormal, offsetPt),
|
|
190
|
+
c2xPt: offsetX(segment.c2xPt, controlNormal, offsetPt),
|
|
191
|
+
c2yPt: offsetY(segment.c2yPt, controlNormal, offsetPt),
|
|
192
|
+
xPt: offsetX(segment.xPt, endNormal, offsetPt),
|
|
193
|
+
yPt: offsetY(segment.yPt, endNormal, offsetPt)
|
|
194
|
+
};
|
|
195
|
+
})
|
|
196
|
+
};
|
|
197
|
+
}
|
|
99
198
|
function writeFillAndStroke(writer, fill, stroke) {
|
|
100
199
|
if (fill !== void 0) writeRgbOperator(writer, fill, "rg");
|
|
101
200
|
if (stroke !== void 0) {
|
|
@@ -110,11 +209,36 @@ function writeRect(writer, item) {
|
|
|
110
209
|
writer.writeAscii(`${formatNumber(item.xPt)} ${formatNumber(item.yPt)} ${formatNumber(item.widthPt)} ${formatNumber(item.heightPt)} re\n`);
|
|
111
210
|
writer.writeAscii(`${paint}\n`);
|
|
112
211
|
}
|
|
212
|
+
function writeDoubleLine(writer, item) {
|
|
213
|
+
writeRgbOperator(writer, item.color, "RG");
|
|
214
|
+
const normal = chordNormal(item.x1Pt, item.y1Pt, item.x2Pt, item.y2Pt);
|
|
215
|
+
if (normal === void 0) {
|
|
216
|
+
writer.writeAscii(`${formatNumber(item.widthPt)} w\n`);
|
|
217
|
+
writer.writeAscii(`${formatNumber(item.x1Pt)} ${formatNumber(item.y1Pt)} m ${formatNumber(item.x2Pt)} ${formatNumber(item.y2Pt)} l\n`);
|
|
218
|
+
writer.writeAscii("S\n");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const ruleWidthPt = item.widthPt / DOUBLE_RULE_BANDS;
|
|
222
|
+
const ruleOffsetPt = item.widthPt / DOUBLE_RULE_BANDS;
|
|
223
|
+
writer.writeAscii(`${formatNumber(ruleWidthPt)} w\n`);
|
|
224
|
+
for (const sign of DOUBLE_RULE_SIGNS) {
|
|
225
|
+
const dx = normal.x * ruleOffsetPt * sign;
|
|
226
|
+
const dy = normal.y * ruleOffsetPt * sign;
|
|
227
|
+
writer.writeAscii(`${formatPoint(item.x1Pt + dx, item.y1Pt + dy)} m ${formatPoint(item.x2Pt + dx, item.y2Pt + dy)} l\n`);
|
|
228
|
+
writer.writeAscii("S\n");
|
|
229
|
+
}
|
|
230
|
+
}
|
|
113
231
|
function writeLine(writer, item) {
|
|
232
|
+
if (item.style === "double") {
|
|
233
|
+
writeDoubleLine(writer, item);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
114
236
|
writeRgbOperator(writer, item.color, "RG");
|
|
115
237
|
writer.writeAscii(`${formatNumber(item.widthPt)} w\n`);
|
|
238
|
+
const styled = writeStrokeStyleState(writer, item.style, item.widthPt);
|
|
116
239
|
writer.writeAscii(`${formatNumber(item.x1Pt)} ${formatNumber(item.y1Pt)} m ${formatNumber(item.x2Pt)} ${formatNumber(item.y2Pt)} l\n`);
|
|
117
240
|
writer.writeAscii("S\n");
|
|
241
|
+
if (styled) resetStrokeStyleState(writer, item.style);
|
|
118
242
|
}
|
|
119
243
|
function writeEllipse(writer, item) {
|
|
120
244
|
const paint = paintOperatorFor(item.fill, item.stroke);
|
|
@@ -140,12 +264,39 @@ function writeSubpath(writer, subpath) {
|
|
|
140
264
|
else writer.writeAscii(`${formatPoint(segment.c1xPt, segment.c1yPt)} ${formatPoint(segment.c2xPt, segment.c2yPt)} ${formatPoint(segment.xPt, segment.yPt)} c\n`);
|
|
141
265
|
if (subpath.closed) writer.writeAscii("h\n");
|
|
142
266
|
}
|
|
267
|
+
function writeDoublePath(writer, item, stroke) {
|
|
268
|
+
if (item.fill !== void 0) {
|
|
269
|
+
writeRgbOperator(writer, item.fill, "rg");
|
|
270
|
+
for (const subpath of item.subpaths) writeSubpath(writer, subpath);
|
|
271
|
+
writer.writeAscii(`${item.fillRule === "evenodd" ? "f*" : "f"}\n`);
|
|
272
|
+
}
|
|
273
|
+
const ruleWidthPt = stroke.widthPt / DOUBLE_RULE_BANDS;
|
|
274
|
+
const ruleOffsetPt = stroke.widthPt / DOUBLE_RULE_BANDS;
|
|
275
|
+
writeRgbOperator(writer, stroke.color, "RG");
|
|
276
|
+
writer.writeAscii(`${formatNumber(ruleWidthPt)} w\n`);
|
|
277
|
+
for (const sign of DOUBLE_RULE_SIGNS) {
|
|
278
|
+
for (const subpath of item.subpaths) writeSubpath(writer, offsetSubpath(subpath, ruleOffsetPt * sign));
|
|
279
|
+
writer.writeAscii("S\n");
|
|
280
|
+
}
|
|
281
|
+
}
|
|
143
282
|
function writePath(writer, item) {
|
|
144
283
|
const paint = paintOperatorFor(item.fill, item.stroke, item.fillRule);
|
|
145
284
|
if (paint === void 0) return;
|
|
285
|
+
if (item.stroke === void 0) {
|
|
286
|
+
writeFillAndStroke(writer, item.fill, void 0);
|
|
287
|
+
for (const subpath of item.subpaths) writeSubpath(writer, subpath);
|
|
288
|
+
writer.writeAscii(`${paint}\n`);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (item.style === "double") {
|
|
292
|
+
writeDoublePath(writer, item, item.stroke);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
146
295
|
writeFillAndStroke(writer, item.fill, item.stroke);
|
|
296
|
+
const styled = writeStrokeStyleState(writer, item.style, item.stroke.widthPt);
|
|
147
297
|
for (const subpath of item.subpaths) writeSubpath(writer, subpath);
|
|
148
298
|
writer.writeAscii(`${paint}\n`);
|
|
299
|
+
if (styled) resetStrokeStyleState(writer, item.style);
|
|
149
300
|
}
|
|
150
301
|
function writeImage(writer, item, context) {
|
|
151
302
|
const image = context.resolveImage(item.imageId);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pdf-codec",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Hand-written, dependency-minimal PDF codec: parses arbitrary real-world PDFs and generates new ones, built on document-schema.js's LayoutDocument pivot and Zod 4 codecs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"license": "MIT",
|
|
71
71
|
"packageManager": "pnpm@11.6.0",
|
|
72
72
|
"dependencies": {
|
|
73
|
-
"document-schema.js": "^2.
|
|
73
|
+
"document-schema.js": "^2.1.0",
|
|
74
74
|
"fflate": "^0.8.3",
|
|
75
75
|
"zod": "^4.4.3"
|
|
76
76
|
},
|