@jasy/pdf 1.0.0-beta.6 → 1.0.0-beta.7

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/dist/edit/fill.js CHANGED
@@ -6,6 +6,7 @@ import { flattenInto } from "./flatten.js";
6
6
  import { getArrayBuffer } from "../utils/utf8-to-windows1252-encoder.js";
7
7
  import { get, isDict, isRef } from "./objects.js";
8
8
  import { encodePdfText } from "../text/pdf-text-string.js";
9
+ import { preloadStandardFonts } from "../utils/afm-parser.js";
9
10
  /** Field flags we have to honour when filling (see `forms/acroform.ts` for the writer's side). */
10
11
  const FF_READ_ONLY = 1 << 0;
11
12
  const FF_MULTI_SELECT = 1 << 21;
@@ -227,6 +228,7 @@ function plan(field, value, enciphered) {
227
228
  * @param values field name -> value. Names are the fully qualified ones `readAcroForm` reports.
228
229
  */
229
230
  export async function fillForm(bytes, values, options = {}) {
231
+ await preloadStandardFonts(); // a field's new face is measured in Helvetica
230
232
  // Two option combinations describe a document nobody wants. Refused before the file is opened, since
231
233
  // the answer does not depend on it.
232
234
  if (options.fieldAppearances === false && options.needAppearances === false) {
@@ -1,3 +1,4 @@
1
+ import { preloadStandardFonts } from "../utils/afm-parser.js";
1
2
  import { PdfDocument } from "./document.js";
2
3
  import { readAcroForm } from "./acroform-reader.js";
3
4
  import { bakeAppearance, bakeButtonStates, readLook } from "./appearance.js";
@@ -430,6 +431,7 @@ function pruneKids(doc, list, gone, out = [], depth = 0) {
430
431
  }
431
432
  /** Flatten an existing form: its values become page content and stop being editable. */
432
433
  export async function flattenForm(bytes, options = {}) {
434
+ await preloadStandardFonts(); // a widget without a face gets one, measured in Helvetica
433
435
  const doc = await PdfDocument.open(bytes, {
434
436
  password: options.password,
435
437
  maxStreamSize: options.maxStreamSize,
@@ -1,3 +1,4 @@
1
+ import { preloadStandardFonts } from "../utils/afm-parser.js";
1
2
  import { FontStyle, PDFObjectManager } from "../utils/pdf-object-manager.js";
2
3
  import { PDFRenderer } from "./pdf-renderer.js";
3
4
  // Re-exported so existing `../renderer` consumers keep importing these from here.
@@ -97,6 +98,8 @@ export class PDFDocument {
97
98
  }
98
99
  beforeRenderer() { }
99
100
  static async render() {
101
+ // Before build(): a subclass may measure text there, through `objectManager`.
102
+ await preloadStandardFonts();
100
103
  const instance = new this();
101
104
  instance.child = instance.build();
102
105
  return await PDFRenderer.render(instance.child, instance._objectManager);
@@ -52,9 +52,12 @@ import { RotatedElement } from "../elements/layout/rotated-element.js";
52
52
  import { RotatedBoxElement } from "../elements/layout/rotated-box-element.js";
53
53
  import { RotatedRenderer } from "./rotated-renderer.js";
54
54
  import { collectImageElements } from "../layout/collect-images.js";
55
+ import { preloadStandardFonts } from "../utils/afm-parser.js";
55
56
  import { DEFAULT_TEXT_STYLE, mergeTextStyle } from "../text/text-style.js";
56
57
  export class PDFRenderer {
57
58
  static async render(document, objectManager) {
59
+ // Layout measures synchronously, so the standard font metrics are loaded before it starts.
60
+ await preloadStandardFonts();
58
61
  // Register all Renderer
59
62
  RendererRegistry.register(TextElement, TextRenderer.render);
60
63
  RendererRegistry.register(ContainerElement, ContainerRenderer.render);
@@ -1,21 +1,34 @@
1
1
  import type { FontVerticals } from "../text/line-metrics.js";
2
2
  import type { FontDecoration } from "../text/text-decoration.js";
3
- export declare function standardParser(fullName: string, data: string): AFMParser;
3
+ /** One standard font's metrics, pre-parsed from its AFM file by scripts/gen-font-data.mjs. */
4
+ export interface StandardFontData {
5
+ /** FontBBox, in glyph units (1000 / em). */
6
+ bbox: number[];
7
+ /** UnderlinePosition (negative: below the baseline), UnderlineThickness. */
8
+ underline: number[];
9
+ capHeight: number;
10
+ xHeight: number;
11
+ /** The advance of every glyph by index; 0 where the font has none. */
12
+ widths: number[];
13
+ /** [first, count, second - previous second, value, ...] */
14
+ kerning: number[];
15
+ }
16
+ /** The 14 names, known without their data: registering a font must not wait for it. */
17
+ export declare const STANDARD_FONT_NAMES: ReadonlySet<string>;
18
+ /**
19
+ * Loads the metrics of the 14 standard fonts. Every render does this itself; call it ahead of time to
20
+ * have the first PDF without the wait, or to measure outside a render. Once per process.
21
+ */
22
+ export declare function preloadStandardFonts(): Promise<void>;
23
+ export declare function standardParser(fullName: string): AFMParser;
24
+ /** A standard font's metrics, read on first use - by then a render has loaded them. */
4
25
  export declare class AFMParser {
5
- private advanceWidths;
6
- private kerningPairs;
7
- private glyphMap;
8
- private bbox;
26
+ private readonly fullName;
27
+ private font?;
9
28
  private cachedVerticals?;
10
- private underlinePosition;
11
- private underlineThickness;
12
- private capHeight;
13
- private xHeight;
14
29
  private cachedDecoration?;
15
- constructor(afmData: string);
16
- private getGlyphName;
17
- private parseAFMData;
18
- private parseCharMetrics;
30
+ constructor(fullName: string);
31
+ private get data();
19
32
  /**
20
33
  * The font's LINE metrics, as fractions of the em: how far a line box must reach above and below
21
34
  * the baseline. Taken from the `FontBBox`.
@@ -41,8 +54,9 @@ export declare class AFMParser {
41
54
  * strikethrough crosses at half of that. Still read from the font, not guessed.
42
55
  */
43
56
  decoration(): FontDecoration;
44
- getAdvanceWidth(charName: string): number;
45
- /** The pair's kerning. The AFM keys its pairs by GLYPH name (`KPX T comma`), like its widths - so the
46
- * characters are named first, or only letter-letter pairs (where the two coincide) would ever hit. */
57
+ /** One character's advance. A string of several characters is not a glyph name here: it measures 0. */
58
+ getAdvanceWidth(char: string): number;
59
+ /** The pair's kerning. The AFM keys its pairs by GLYPH, like its widths - so the characters are
60
+ * mapped to glyphs first, or only letter-letter pairs (where the two coincide) would ever hit. */
47
61
  getKerning(firstChar: string, secondChar: string): number;
48
62
  }
@@ -1,118 +1,79 @@
1
- import { AGL } from "../assets/font-data.js";
2
- /** The Adobe Glyph List: code point -> glyph name. Constant, so it is parsed once per process and
3
- * shared by every face rather than rebuilt per `AFMParser`. */
4
- let sharedGlyphMap;
5
- function glyphList() {
6
- if (sharedGlyphMap)
7
- return sharedGlyphMap;
8
- const map = {};
9
- for (const line of AGL.split("\n")) {
10
- const parts = line.trim().split(";");
11
- if (parts.length >= 2)
12
- map[String.fromCharCode(parseInt(parts[0], 16))] = parts[1];
13
- }
14
- // Windows-1252 characters the AGLFN leaves out, by the glyph WinAnsiEncoding draws them with
15
- // (ISO 32000-1, Annex D). Unnamed, they were measured as "?" while the viewer drew the real glyph.
16
- map["\u00a0"] = "space";
17
- map["\u00ad"] = "hyphen";
18
- map["\u00b2"] = "twosuperior";
19
- map["\u00b3"] = "threesuperior";
20
- map["\u00b9"] = "onesuperior";
21
- return (sharedGlyphMap = map);
1
+ /** The 14 names, known without their data: registering a font must not wait for it. */
2
+ export const STANDARD_FONT_NAMES = new Set([
3
+ "Courier",
4
+ "Courier-Bold",
5
+ "Courier-BoldOblique",
6
+ "Courier-Oblique",
7
+ "Helvetica",
8
+ "Helvetica-Bold",
9
+ "Helvetica-BoldOblique",
10
+ "Helvetica-Oblique",
11
+ "Symbol",
12
+ "Times-Bold",
13
+ "Times-BoldItalic",
14
+ "Times-Italic",
15
+ "Times-Roman",
16
+ "ZapfDingbats",
17
+ ]);
18
+ // The metrics are 155 KB of numbers that only a render needs, so they are loaded then, and an app
19
+ // that ships jasy does not carry them in its start bundle. Layout is synchronous: everything that
20
+ // measures awaits this first (PDFRenderer.render, fillForm, flattenForm).
21
+ let metrics;
22
+ let loading;
23
+ let glyphOf;
24
+ /**
25
+ * Loads the metrics of the 14 standard fonts. Every render does this itself; call it ahead of time to
26
+ * have the first PDF without the wait, or to measure outside a render. Once per process.
27
+ */
28
+ export function preloadStandardFonts() {
29
+ // ".js", not ".ts": tsc rewrites the extension of a static import, not of a dynamic one.
30
+ return (loading ?? (loading = import("../assets/standard-fonts.js").then((m) => {
31
+ metrics = m;
32
+ glyphOf = new Map();
33
+ for (let i = 0; i < m.CHARS.length; i += 2) {
34
+ glyphOf.set(String.fromCharCode(m.CHARS[i]), m.CHARS[i + 1]);
35
+ }
36
+ }, (e) => {
37
+ loading = undefined; // a chunk lost to a deploy: the next render tries again
38
+ throw e;
39
+ })));
22
40
  }
23
- /** One parser per standard-14 face for the whole process: the AFM data is a constant and a parsed
41
+ /** One parser per standard-14 face for the whole process: the metrics are constants and a parsed
24
42
  * face is read-only, so every document can share one. */
25
43
  const STANDARD_PARSERS = new Map();
26
- export function standardParser(fullName, data) {
44
+ export function standardParser(fullName) {
27
45
  let parser = STANDARD_PARSERS.get(fullName);
28
46
  if (!parser)
29
- STANDARD_PARSERS.set(fullName, (parser = new AFMParser(data)));
47
+ STANDARD_PARSERS.set(fullName, (parser = new AFMParser(fullName)));
30
48
  return parser;
31
49
  }
50
+ /** A standard font's metrics, read on first use - by then a render has loaded them. */
32
51
  export class AFMParser {
33
- constructor(afmData) {
34
- this.advanceWidths = {};
35
- this.kerningPairs = {};
36
- this.glyphMap = glyphList();
37
- // The font's bounding box in glyph space (1000 units / em), from the AFM header. This - not the
38
- // `Ascender` line - is what a line box is built from; see `verticals()`.
39
- this.bbox = [0, 0, 0, 0];
40
- // Glyph metrics from the AFM header, for underline / strikethrough. All 14 standard fonts carry
41
- // the underline pair; Symbol and ZapfDingbats have no CapHeight/XHeight (they have no lowercase).
42
- this.underlinePosition = 0; // negative, as the AFM writes it: below the baseline
43
- this.underlineThickness = 0;
44
- this.capHeight = 0;
45
- this.xHeight = 0;
46
- this.parseAFMData(afmData);
52
+ constructor(fullName) {
53
+ this.fullName = fullName;
47
54
  }
48
- getGlyphName(char) {
49
- return this.glyphMap[char] || char;
50
- }
51
- parseAFMData(afmData) {
52
- var _a;
53
- const lines = afmData.split("\n");
54
- let inCharMetrics = false;
55
- for (let line of lines) {
56
- line = line.trim();
57
- if (line.startsWith("StartCharMetrics")) {
58
- inCharMetrics = true;
59
- continue;
60
- }
61
- if (line.startsWith("EndCharMetrics")) {
62
- inCharMetrics = false;
63
- continue;
64
- }
65
- if (inCharMetrics) {
66
- const charData = this.parseCharMetrics(line);
67
- if (charData) {
68
- const { charName, wx } = charData;
69
- this.advanceWidths[charName] = wx;
70
- }
71
- }
72
- if (line.startsWith("UnderlinePosition ")) {
73
- this.underlinePosition = parseFloat(line.slice(18));
74
- }
75
- if (line.startsWith("UnderlineThickness ")) {
76
- this.underlineThickness = parseFloat(line.slice(19));
77
- }
78
- if (line.startsWith("CapHeight ")) {
79
- this.capHeight = parseFloat(line.slice(10));
80
- }
81
- if (line.startsWith("XHeight ")) {
82
- this.xHeight = parseFloat(line.slice(8));
83
- }
84
- if (line.startsWith("FontBBox ")) {
85
- const [x0, y0, x1, y1] = line.slice(9).trim().split(/\s+/).map(parseFloat);
86
- this.bbox = [x0, y0, x1, y1];
87
- }
88
- if (line.startsWith("KPX")) {
89
- const parts = line.split(/\s+/);
90
- const firstChar = parts[1];
91
- const secondChar = parts[2];
92
- const kerning = parseFloat(parts[3]);
93
- // Nested by first glyph, then second. A flat `${a}-${b}` key would have to be BUILT on every
94
- // lookup, and getKerning runs once per adjacent character pair - that string allocation was a
95
- // quarter of a standard-font render.
96
- ((_a = this.kerningPairs)[firstChar] ?? (_a[firstChar] = {}))[secondChar] = kerning;
97
- }
55
+ get data() {
56
+ if (this.font)
57
+ return this.font;
58
+ const raw = metrics?.FONTS[this.fullName];
59
+ if (!raw) {
60
+ throw new Error(metrics
61
+ ? `@jasy/pdf: "${this.fullName}" is not one of the 14 standard fonts.`
62
+ : "@jasy/pdf: the standard font metrics are not loaded. A render loads them itself; to " +
63
+ "measure text outside one, await preloadStandardFonts() first.");
98
64
  }
99
- }
100
- parseCharMetrics(line) {
101
- const parts = line.split(";").map((part) => part.trim());
102
- let charName = "";
103
- let wx = 0;
104
- for (const part of parts) {
105
- if (part.startsWith("N ")) {
106
- charName = part.split(" ")[1];
107
- }
108
- if (part.startsWith("WX ")) {
109
- wx = parseFloat(part.split(" ")[1]);
65
+ const kerning = new Map();
66
+ for (let i = 0; i < raw.kerning.length;) {
67
+ const first = raw.kerning[i++];
68
+ const pairs = new Map();
69
+ let second = 0;
70
+ for (let n = raw.kerning[i++]; n > 0; n--) {
71
+ second += raw.kerning[i++];
72
+ pairs.set(second, raw.kerning[i++]);
110
73
  }
74
+ kerning.set(first, pairs);
111
75
  }
112
- if (charName && wx) {
113
- return { charName, wx };
114
- }
115
- return null;
76
+ return (this.font = { ...raw, kerning });
116
77
  }
117
78
  /**
118
79
  * The font's LINE metrics, as fractions of the em: how far a line box must reach above and below
@@ -133,7 +94,7 @@ export class AFMParser {
133
94
  verticals() {
134
95
  if (this.cachedVerticals)
135
96
  return this.cachedVerticals;
136
- const [, yMin, , yMax] = this.bbox;
97
+ const [, yMin, , yMax] = this.data.bbox;
137
98
  this.cachedVerticals = { ascent: yMax / 1000, descent: -yMin / 1000, lineGap: 0 };
138
99
  return this.cachedVerticals;
139
100
  }
@@ -147,21 +108,30 @@ export class AFMParser {
147
108
  decoration() {
148
109
  if (this.cachedDecoration)
149
110
  return this.cachedDecoration;
150
- const capHeight = this.capHeight || this.bbox[3];
111
+ const { bbox, underline, xHeight } = this.data;
112
+ const capHeight = this.data.capHeight || bbox[3];
151
113
  this.cachedDecoration = {
152
- underlinePosition: -this.underlinePosition / 1000, // AFM writes it negative (below the baseline)
153
- underlineThickness: this.underlineThickness / 1000,
114
+ underlinePosition: -underline[0] / 1000, // AFM writes it negative (below the baseline)
115
+ underlineThickness: underline[1] / 1000,
154
116
  capHeight: capHeight / 1000,
155
- xHeight: (this.xHeight || capHeight / 2) / 1000,
117
+ xHeight: (xHeight || capHeight / 2) / 1000,
156
118
  };
157
119
  return this.cachedDecoration;
158
120
  }
159
- getAdvanceWidth(charName) {
160
- return this.advanceWidths[this.getGlyphName(charName)] || 0;
121
+ /** One character's advance. A string of several characters is not a glyph name here: it measures 0. */
122
+ getAdvanceWidth(char) {
123
+ const { widths } = this.data; // first: it throws when the metrics are not loaded
124
+ const glyph = glyphOf.get(char);
125
+ return glyph === undefined ? 0 : widths[glyph];
161
126
  }
162
- /** The pair's kerning. The AFM keys its pairs by GLYPH name (`KPX T comma`), like its widths - so the
163
- * characters are named first, or only letter-letter pairs (where the two coincide) would ever hit. */
127
+ /** The pair's kerning. The AFM keys its pairs by GLYPH, like its widths - so the characters are
128
+ * mapped to glyphs first, or only letter-letter pairs (where the two coincide) would ever hit. */
164
129
  getKerning(firstChar, secondChar) {
165
- return this.kerningPairs[this.getGlyphName(firstChar)]?.[this.getGlyphName(secondChar)] ?? 0;
130
+ const { kerning } = this.data;
131
+ const first = glyphOf.get(firstChar);
132
+ const second = glyphOf.get(secondChar);
133
+ if (first === undefined || second === undefined)
134
+ return 0;
135
+ return kerning.get(first)?.get(second) ?? 0;
166
136
  }
167
137
  }
@@ -1,9 +1,8 @@
1
1
  import { pageFormats, PageSize } from "../constants/page-sizes.js";
2
- import { STANDARD_AFM } from "../assets/font-data.js";
3
2
  import { md5, md5Hex } from "./md5.js";
4
3
  import { zlibSync } from "fflate";
5
4
  import { bytesFromLatin1, latin1FromBytes } from "./bytes.js";
6
- import { standardParser } from "./afm-parser.js";
5
+ import { STANDARD_FONT_NAMES, standardParser } from "./afm-parser.js";
7
6
  import { mergeSpans, TTFParser } from "./ttf-parser.js";
8
7
  import { WoffError, isWoff, woffToSfnt } from "./woff.js";
9
8
  import { isWoff2 } from "./woff2.js";
@@ -612,13 +611,12 @@ endstream`;
612
611
  this.fontEpoch++;
613
612
  this.verticalsCache.delete(fontName);
614
613
  this.decorationCache.delete(fontName);
615
- const data = STANDARD_AFM[fullName];
616
- if (data !== undefined) {
614
+ if (STANDARD_FONT_NAMES.has(fullName)) {
617
615
  this.afmParsers.push({
618
616
  fontName,
619
617
  fontStyle,
620
618
  fullFontName: fullName,
621
- parser: standardParser(fullName, data),
619
+ parser: standardParser(fullName),
622
620
  });
623
621
  }
624
622
  const resourceNumber = this.objects.length + 1; // New resource number
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jasy/pdf",
3
- "version": "1.0.0-beta.6",
3
+ "version": "1.0.0-beta.7",
4
4
  "description": "Create PDFs in TypeScript: declarative, component-based layout with real pagination, in Node or the browser. Its own engine - no headless browser, no Java. Powers ZUGFeRD / XRechnung e-invoices.",
5
5
  "keywords": [
6
6
  "accessibility",
@@ -56,14 +56,12 @@
56
56
  "@types/node": "^25.9.3",
57
57
  "@vitest/coverage-v8": "^2.1.1",
58
58
  "hyphen": "^1.14.1",
59
- "ncp": "^2.0.0",
60
59
  "oxfmt": "^0.55.0",
61
60
  "oxlint": "^1.70.0",
62
61
  "typescript": "^5.5.4",
63
62
  "vitest": "^2.1.1"
64
63
  },
65
64
  "scripts": {
66
- "copy-assets": "ncp ./src/lib/assets ./dist/assets",
67
65
  "cli": "pnpm --filter @jasy/cli dev",
68
66
  "cli:watch": "pnpm --filter @jasy/cli watch",
69
67
  "play": "pnpm --filter @jasy/playground play",
@@ -71,7 +69,7 @@
71
69
  "play:react": "pnpm --filter @jasy/react play",
72
70
  "test": "vitest",
73
71
  "test:coverage": "vitest --coverage",
74
- "build": "tsc && node scripts/fix-dts-ext.mjs && pnpm run copy-assets",
72
+ "build": "tsc && node scripts/fix-dts-ext.mjs",
75
73
  "lint": "oxlint",
76
74
  "lint:fix": "oxlint --fix",
77
75
  "fmt": "oxfmt",