@jasy/pdf 1.0.0-beta.1 → 1.0.0-beta.2

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.
@@ -26,21 +26,63 @@ export function codePointCount(text) {
26
26
  n++;
27
27
  return n;
28
28
  }
29
+ /**
30
+ * Glyph widths plus kerning, memoised per metrics object - the line breaker asks for the same run
31
+ * many times over.
32
+ *
33
+ * Keyed by nested maps on primitives, never a composed string key: building one allocates per call,
34
+ * which costs more than the lookup saves.
35
+ *
36
+ * `letterSpacing` is not in the key: it is added outside, so a spaced and an unspaced run share the
37
+ * width. Font size IS in it - scaling a cached em width drifts in the last bit, which is enough to
38
+ * move a line break. Kerning is a flag on the metrics rather than a key, so the entry records it and
39
+ * is thrown away when it flips; the same document can be rendered twice with different options.
40
+ */
41
+ const ADVANCE_CACHE = new WeakMap();
42
+ function baseAdvance(metrics, text, font) {
43
+ let entry = ADVANCE_CACHE.get(metrics);
44
+ const epoch = metrics.fontEpoch ?? 0;
45
+ const kerning = metrics.kerningEnabled;
46
+ // A face registered since we measured makes a family name mean something else; a flipped kerning
47
+ // flag makes every width wrong. Either way the answers are stale.
48
+ if (!entry || entry.epoch !== epoch || entry.kerning !== kerning) {
49
+ entry = { epoch, kerning, byFamily: new Map() };
50
+ ADVANCE_CACHE.set(metrics, entry);
51
+ }
52
+ let byStyle = entry.byFamily.get(font.fontFamily);
53
+ if (!byStyle)
54
+ entry.byFamily.set(font.fontFamily, (byStyle = new Map()));
55
+ let byLigatures = byStyle.get(font.fontStyle);
56
+ if (!byLigatures)
57
+ byStyle.set(font.fontStyle, (byLigatures = new Map()));
58
+ let bySize = byLigatures.get(font.ligatures);
59
+ if (!bySize)
60
+ byLigatures.set(font.ligatures, (bySize = new Map()));
61
+ let byText = bySize.get(font.fontSize);
62
+ if (!byText)
63
+ bySize.set(font.fontSize, (byText = new Map()));
64
+ const hit = byText.get(text);
65
+ if (hit !== undefined)
66
+ return hit;
67
+ let advance = metrics.getStringWidth(text, font.fontFamily, font.fontSize, font.fontStyle, font.ligatures);
68
+ if (metrics.kerningEnabled) {
69
+ let units = 0;
70
+ for (const k of metrics.getKernPairs(text, font.fontFamily, font.fontStyle, font.ligatures))
71
+ units += k;
72
+ advance += (units / 1000) * font.fontSize; // kern units are em/1000
73
+ }
74
+ byText.set(text, advance);
75
+ return advance;
76
+ }
29
77
  /** The advance of `text` in points: the plain glyph widths, plus kerning (if the document has it on),
30
78
  * plus one `letterSpacing` per code point. */
31
79
  export function runAdvance(metrics, text, font, letterSpacing = 0) {
32
- let advance = metrics.getStringWidth(text, font.fontFamily, font.fontSize, font.fontStyle, font.ligatures);
80
+ let advance = baseAdvance(metrics, text, font);
33
81
  if (letterSpacing !== 0) {
34
82
  // Per DRAWN glyph, which is what `Tc` applies to; only a ligature makes that differ.
35
83
  const glyphs = metrics.shapedGlyphCount?.(text, font.fontFamily, font.fontStyle, font.ligatures) ??
36
84
  codePointCount(text);
37
85
  advance += glyphs * letterSpacing;
38
86
  }
39
- if (metrics.kerningEnabled) {
40
- let units = 0;
41
- for (const k of metrics.getKernPairs(text, font.fontFamily, font.fontStyle, font.ligatures))
42
- units += k;
43
- advance += (units / 1000) * font.fontSize; // kern units are em/1000
44
- }
45
87
  return advance;
46
88
  }
@@ -12,7 +12,6 @@ export declare class AFMParser {
12
12
  private xHeight;
13
13
  private cachedDecoration?;
14
14
  constructor(afmData: string);
15
- private loadGlyphList;
16
15
  private getGlyphName;
17
16
  private parseAFMData;
18
17
  private parseCharMetrics;
@@ -1,9 +1,23 @@
1
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
+ return (sharedGlyphMap = map);
15
+ }
2
16
  export class AFMParser {
3
17
  constructor(afmData) {
4
18
  this.advanceWidths = {};
5
19
  this.kerningPairs = {};
6
- this.glyphMap = {};
20
+ this.glyphMap = glyphList();
7
21
  // The font's bounding box in glyph space (1000 units / em), from the AFM header. This - not the
8
22
  // `Ascender` line - is what a line box is built from; see `verticals()`.
9
23
  this.bbox = [0, 0, 0, 0];
@@ -14,21 +28,6 @@ export class AFMParser {
14
28
  this.capHeight = 0;
15
29
  this.xHeight = 0;
16
30
  this.parseAFMData(afmData);
17
- this.loadGlyphList();
18
- }
19
- loadGlyphList() {
20
- const lines = AGL.split("\n");
21
- for (const line of lines) {
22
- const parts = line.trim().split(";");
23
- if (parts.length >= 2) {
24
- const unicodeHex = parts[0];
25
- const glyphName = parts[1];
26
- // Converts the Unicode hex code into the corresponding character
27
- const unicodeChar = String.fromCharCode(parseInt(unicodeHex, 16));
28
- // Adds the character as the key and the glyph name as the value in the list
29
- this.glyphMap[unicodeChar] = glyphName;
30
- }
31
- }
32
31
  }
33
32
  getGlyphName(char) {
34
33
  return this.glyphMap[char] || char;
@@ -20,6 +20,9 @@ export interface FontMetrics {
20
20
  /** Whether kerning is on for this document. `runAdvance` reads it so a measured advance matches the
21
21
  * drawn one (the backend emits a `TJ` under the same flag). */
22
22
  readonly kerningEnabled: boolean;
23
+ /** Bumped whenever a face is registered, so a memoised measurement knows to drop it: the same family
24
+ * name can mean a different face afterwards. Optional - leaving it out means never invalidating. */
25
+ readonly fontEpoch?: number;
23
26
  /** Per-adjacent-pair kerning of `text`, in em/1000 (negative tightens); length `codePoints - 1`,
24
27
  * zero next to a space. Only meaningful when `kerningEnabled`. */
25
28
  getKernPairs(text: string, fontFamily: string, fontStyle: FontStyle, ligatures?: boolean): number[];
@@ -1,6 +1,7 @@
1
1
  import type { OverflowPolicy } from "../layout/fragmentation.js";
2
2
  import { TTFParser } from "./ttf-parser.js";
3
3
  import { type ShapedGlyph } from "../text/shape.js";
4
+ export declare function featureKey(f: readonly string[]): string;
4
5
  import type { SecurityHandler } from "../crypto/security-handler.js";
5
6
  import type { Gradient } from "../ir/display-list.js";
6
7
  import type { FontVerticals } from "../text/line-metrics.js";
@@ -40,7 +41,12 @@ export declare class PDFObjectManager implements FontMetrics {
40
41
  private verticalsCache;
41
42
  private decorationCache;
42
43
  private customFontEmit;
44
+ /** Shaped runs, cached face -> feature set -> text. Nested, not one map under a composed key: that
45
+ * key would carry the whole run's text, so every lookup would copy it into a fresh string. */
43
46
  private shapeCache;
47
+ /** Bumped by every font registration; `runAdvance` watches it to drop memoised widths, since a
48
+ * family name can mean a different face afterwards. */
49
+ fontEpoch: number;
44
50
  private shapedOrigins;
45
51
  private attachments;
46
52
  private xmpMetadata?;
@@ -13,8 +13,18 @@ import { shapeRun } from "../text/shape.js";
13
13
  * drew for it. On by default like kerning - CSS, browsers and every other renderer do the same,
14
14
  * and nobody should have to know the word to get text that is set properly. */
15
15
  const LATIN_FEATURES = ["liga"];
16
- /** What a run is shaped with. Arabic joining is unconditional; this is only the Latin part. */
17
- const features = (ligatures) => (ligatures ? LATIN_FEATURES : []);
16
+ /** Stable, so `features(false)` does not allocate per call. */
17
+ const NO_FEATURES = [];
18
+ /** A feature set's cache key. Memoised only for the two arrays this module owns - a caller's array is
19
+ * joined every time, since holding on to its key would outlive a mutation of it. */
20
+ const OWN_FEATURE_KEYS = new Map([
21
+ [LATIN_FEATURES, LATIN_FEATURES.join(",")],
22
+ [NO_FEATURES, ""],
23
+ ]);
24
+ export function featureKey(f) {
25
+ return OWN_FEATURE_KEYS.get(f) ?? f.join(",");
26
+ }
27
+ const features = (ligatures) => ligatures ? LATIN_FEATURES : NO_FEATURES;
18
28
  import { getArrayBuffer, isWindows1252 } from "./utf8-to-windows1252-encoder.js";
19
29
  import { isEmojiCodePoint } from "../text/emoji-codepoints.js";
20
30
  import { StructTree } from "./struct-tree.js";
@@ -171,7 +181,12 @@ export class PDFObjectManager {
171
181
  // program is subsetted and the objects filled in by finalizeCustomFonts(), after the render pass.
172
182
  this.customFontEmit = new Map();
173
183
  // Shaped runs, keyed by font + text; `null` means "asked, and there was nothing to shape".
184
+ /** Shaped runs, cached face -> feature set -> text. Nested, not one map under a composed key: that
185
+ * key would carry the whole run's text, so every lookup would copy it into a fresh string. */
174
186
  this.shapeCache = new Map();
187
+ /** Bumped by every font registration; `runAdvance` watches it to drop memoised widths, since a
188
+ * family name can mean a different face afterwards. */
189
+ this.fontEpoch = 0;
175
190
  // Per font, glyph -> the code points it stands for. A shaped glyph is often absent from the cmap (a
176
191
  // ligature) or maps back to a presentation form, so `ToUnicode` cannot come from the cmap alone.
177
192
  this.shapedOrigins = new Map();
@@ -550,6 +565,7 @@ endstream`;
550
565
  }
551
566
  // Registers a font
552
567
  registerFont(fontName, fontStyle = FontStyle.Normal, fullName = fontName) {
568
+ this.fontEpoch++;
553
569
  if (this.fonts.hasFont(fontName, fontStyle)) {
554
570
  return this.fonts.getFont(fontName, fontStyle); // Already exists? Return it!
555
571
  }
@@ -580,6 +596,7 @@ endstream`;
580
596
  // metrics and emits its PDF font objects. All variants share the family `name`; bold/italic are
581
597
  // separate .ttf files registered under the same name with a different style.
582
598
  registerCustomFont(name, data, style = FontStyle.Normal) {
599
+ this.fontEpoch++;
583
600
  let byStyle = this.customFonts.get(name);
584
601
  if (!byStyle) {
585
602
  byStyle = new Map();
@@ -767,13 +784,18 @@ endstream`;
767
784
  const resolved = this.resolveCustomStyle(fontFamily, fontStyle);
768
785
  if (!resolved)
769
786
  return undefined;
770
- const key = `${this.customKey(fontFamily, resolved)}\u0000${features.join(",")}\u0000${text}`;
771
- const cached = this.shapeCache.get(key);
787
+ const ttf = this.customFonts.get(fontFamily).get(resolved);
788
+ let byFeature = this.shapeCache.get(ttf);
789
+ if (!byFeature)
790
+ this.shapeCache.set(ttf, (byFeature = new Map()));
791
+ let byText = byFeature.get(featureKey(features));
792
+ if (!byText)
793
+ byFeature.set(featureKey(features), (byText = new Map()));
794
+ const cached = byText.get(text);
772
795
  if (cached !== undefined)
773
796
  return cached ?? undefined;
774
- const ttf = this.customFonts.get(fontFamily).get(resolved);
775
797
  const shaped = shapeRun([...text].map((c) => c.codePointAt(0)), ttf, features) ?? null;
776
- this.shapeCache.set(key, shaped);
798
+ byText.set(text, shaped);
777
799
  if (shaped) {
778
800
  // Recorded at shaping time - the only moment the glyph and its code points are known together.
779
801
  const fontKey = this.customKey(fontFamily, resolved);
@@ -39,9 +39,11 @@ const CP1252_FROM_UNICODE = {
39
39
  * except the C1 range, which the table above fills with printable punctuation instead.
40
40
  */
41
41
  export function isWindows1252(codePoint) {
42
- if (codePoint in CP1252_FROM_UNICODE)
43
- return true;
44
- return codePoint <= 0xff && !(codePoint >= 0x80 && codePoint <= 0x9f);
42
+ // Latin-1 first: every key in the table above is > 0xff, so this range can never be in it. Order
43
+ // matters - this runs per character of every measured string, and the object lookup is the slow half.
44
+ if (codePoint <= 0xff)
45
+ return codePoint < 0x80 || codePoint > 0x9f;
46
+ return codePoint in CP1252_FROM_UNICODE;
45
47
  }
46
48
  /** Encodes a JavaScript string to a Windows-1252 byte buffer (the PDF text encoding). */
47
49
  export function getArrayBuffer(data) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jasy/pdf",
3
- "version": "1.0.0-beta.1",
3
+ "version": "1.0.0-beta.2",
4
4
  "description": "Declarative, component-based PDF generation in pure TypeScript - Flutter-style components, real AFM font metrics, a hand-rolled PDF writer. No headless browser, no Java.",
5
5
  "keywords": [
6
6
  "declarative",