@jasy/pdf 1.0.0-alpha.7 → 1.0.0-alpha.9

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.
Files changed (140) hide show
  1. package/README.md +11 -10
  2. package/dist/api/content.d.ts +5 -4
  3. package/dist/api/content.js +13 -7
  4. package/dist/api/descriptor.js +23 -0
  5. package/dist/api/dimension.d.ts +56 -0
  6. package/dist/api/dimension.js +27 -0
  7. package/dist/api/font-url.d.ts +28 -0
  8. package/dist/api/font-url.js +134 -0
  9. package/dist/api/forms.d.ts +347 -0
  10. package/dist/api/forms.js +189 -0
  11. package/dist/api/gradient.d.ts +53 -0
  12. package/dist/api/gradient.js +97 -0
  13. package/dist/api/index.d.ts +3 -0
  14. package/dist/api/index.js +3 -0
  15. package/dist/api/insets.d.ts +19 -12
  16. package/dist/api/insets.js +18 -7
  17. package/dist/api/layout.d.ts +20 -6
  18. package/dist/api/layout.js +31 -6
  19. package/dist/api/page-builder.d.ts +8 -0
  20. package/dist/api/page-builder.js +12 -0
  21. package/dist/api/structure.d.ts +11 -0
  22. package/dist/api/structure.js +17 -4
  23. package/dist/api/text.d.ts +42 -6
  24. package/dist/api/text.js +29 -5
  25. package/dist/crypto/legacy-handler.d.ts +43 -0
  26. package/dist/crypto/legacy-handler.js +145 -0
  27. package/dist/crypto/security-handler.d.ts +16 -0
  28. package/dist/crypto/security-handler.js +26 -0
  29. package/dist/edit/acroform-reader.d.ts +70 -0
  30. package/dist/edit/acroform-reader.js +126 -0
  31. package/dist/edit/appearance.d.ts +47 -0
  32. package/dist/edit/appearance.js +171 -0
  33. package/dist/edit/document.d.ts +145 -0
  34. package/dist/edit/document.js +642 -0
  35. package/dist/edit/fill.d.ts +65 -0
  36. package/dist/edit/fill.js +424 -0
  37. package/dist/edit/flatten.d.ts +54 -0
  38. package/dist/edit/flatten.js +462 -0
  39. package/dist/edit/index.d.ts +20 -0
  40. package/dist/edit/index.js +18 -0
  41. package/dist/edit/lexer.d.ts +30 -0
  42. package/dist/edit/lexer.js +285 -0
  43. package/dist/edit/objects.d.ts +68 -0
  44. package/dist/edit/objects.js +83 -0
  45. package/dist/edit/writer.d.ts +60 -0
  46. package/dist/edit/writer.js +283 -0
  47. package/dist/elements/container-element.d.ts +21 -1
  48. package/dist/elements/container-element.js +25 -10
  49. package/dist/elements/forms/checkbox-element.d.ts +51 -0
  50. package/dist/elements/forms/checkbox-element.js +48 -0
  51. package/dist/elements/forms/choice-element.d.ts +53 -0
  52. package/dist/elements/forms/choice-element.js +59 -0
  53. package/dist/elements/forms/push-button-element.d.ts +56 -0
  54. package/dist/elements/forms/push-button-element.js +49 -0
  55. package/dist/elements/forms/radio-element.d.ts +52 -0
  56. package/dist/elements/forms/radio-element.js +49 -0
  57. package/dist/elements/forms/signature-element.d.ts +54 -0
  58. package/dist/elements/forms/signature-element.js +47 -0
  59. package/dist/elements/forms/text-field-element.d.ts +61 -0
  60. package/dist/elements/forms/text-field-element.js +54 -0
  61. package/dist/elements/image-element.d.ts +18 -4
  62. package/dist/elements/image-element.js +27 -12
  63. package/dist/elements/index.d.ts +6 -0
  64. package/dist/elements/index.js +6 -0
  65. package/dist/elements/layout/expanded-element.d.ts +3 -1
  66. package/dist/elements/layout/expanded-element.js +12 -3
  67. package/dist/elements/layout/padding-element.d.ts +4 -2
  68. package/dist/elements/layout/padding-element.js +4 -2
  69. package/dist/elements/layout/page-builder-element.js +2 -0
  70. package/dist/elements/pdf-element.d.ts +59 -1
  71. package/dist/elements/pdf-element.js +62 -1
  72. package/dist/elements/rectangle-element.d.ts +20 -6
  73. package/dist/elements/rectangle-element.js +25 -12
  74. package/dist/elements/row-element.d.ts +24 -1
  75. package/dist/elements/row-element.js +25 -10
  76. package/dist/elements/text-element.d.ts +60 -1
  77. package/dist/elements/text-element.js +132 -33
  78. package/dist/forms/acroform.d.ts +63 -0
  79. package/dist/forms/acroform.js +427 -0
  80. package/dist/forms/appearance.d.ts +58 -0
  81. package/dist/forms/appearance.js +204 -0
  82. package/dist/forms/field.d.ts +164 -0
  83. package/dist/forms/field.js +1 -0
  84. package/dist/forms/pdf.d.ts +19 -0
  85. package/dist/forms/pdf.js +28 -0
  86. package/dist/ir/display-list.d.ts +40 -7
  87. package/dist/ir/display-list.js +8 -1
  88. package/dist/layout/alignment.d.ts +10 -0
  89. package/dist/layout/alignment.js +6 -0
  90. package/dist/layout/box-constraints.d.ts +53 -0
  91. package/dist/layout/box-constraints.js +72 -0
  92. package/dist/layout/insets.d.ts +28 -0
  93. package/dist/layout/insets.js +20 -0
  94. package/dist/renderer/form-field-renderer.d.ts +20 -0
  95. package/dist/renderer/form-field-renderer.js +12 -0
  96. package/dist/renderer/image-renderer.js +2 -1
  97. package/dist/renderer/page-renderer.js +8 -2
  98. package/dist/renderer/pdf-backend.d.ts +6 -0
  99. package/dist/renderer/pdf-backend.js +86 -31
  100. package/dist/renderer/pdf-document-renderer.js +8 -1
  101. package/dist/renderer/pdf-renderer.js +19 -1
  102. package/dist/renderer/rectangle-renderer.js +8 -2
  103. package/dist/renderer/text-renderer.d.ts +2 -2
  104. package/dist/renderer/text-renderer.js +150 -42
  105. package/dist/text/advance.js +5 -2
  106. package/dist/text/arabic.d.ts +20 -0
  107. package/dist/text/arabic.js +101 -0
  108. package/dist/text/bidi.d.ts +33 -0
  109. package/dist/text/bidi.js +94 -0
  110. package/dist/text/font-fallback.d.ts +25 -0
  111. package/dist/text/font-fallback.js +38 -0
  112. package/dist/text/line-breaker.d.ts +35 -1
  113. package/dist/text/line-breaker.js +76 -16
  114. package/dist/text/orphans-widows.d.ts +27 -0
  115. package/dist/text/orphans-widows.js +53 -0
  116. package/dist/text/shape.d.ts +29 -0
  117. package/dist/text/shape.js +67 -0
  118. package/dist/text/text-style.d.ts +25 -0
  119. package/dist/text/text-style.js +44 -1
  120. package/dist/utils/dest-registry.d.ts +5 -2
  121. package/dist/utils/dest-registry.js +5 -5
  122. package/dist/utils/flex-layout.d.ts +24 -4
  123. package/dist/utils/flex-layout.js +169 -7
  124. package/dist/utils/font-metrics.d.ts +5 -0
  125. package/dist/utils/gsub.d.ts +36 -0
  126. package/dist/utils/gsub.js +178 -0
  127. package/dist/utils/inflate.d.ts +16 -0
  128. package/dist/utils/inflate.js +52 -0
  129. package/dist/utils/outline.js +1 -2
  130. package/dist/utils/pdf-object-manager.d.ts +39 -0
  131. package/dist/utils/pdf-object-manager.js +166 -15
  132. package/dist/utils/struct-tree.js +2 -3
  133. package/dist/utils/ttf-parser.d.ts +6 -0
  134. package/dist/utils/ttf-parser.js +19 -0
  135. package/dist/utils/utf8-to-windows1252-encoder.d.ts +6 -0
  136. package/dist/utils/utf8-to-windows1252-encoder.js +10 -0
  137. package/dist/utils/woff.d.ts +10 -0
  138. package/dist/utils/woff.js +116 -0
  139. package/dist/validators/element-validator.js +14 -3
  140. package/package.json +10 -2
@@ -5,12 +5,15 @@ import { zlibSync } from "fflate";
5
5
  import { bytesFromLatin1, latin1FromBytes } from "./bytes.js";
6
6
  import { AFMParser } from "./afm-parser.js";
7
7
  import { mergeSpans, TTFParser } from "./ttf-parser.js";
8
+ import { isWoff, woffToSfnt } from "./woff.js";
8
9
  import { subsetTTF } from "./ttf-subsetter.js";
9
- import { getArrayBuffer } from "./utf8-to-windows1252-encoder.js";
10
+ import { shapeRun } from "../text/shape.js";
11
+ import { getArrayBuffer, isWindows1252 } from "./utf8-to-windows1252-encoder.js";
10
12
  import { isEmojiCodePoint } from "../text/emoji-codepoints.js";
11
13
  import { StructTree } from "./struct-tree.js";
12
14
  import { OutlineBuilder } from "./outline.js";
13
15
  import { DestRegistry } from "./dest-registry.js";
16
+ import { AcroFormCollector } from "../forms/acroform.js";
14
17
  // Enums come from the leaf config module (never in a cycle); the config type is
15
18
  // erased at runtime so it can come from the cyclic module safely.
16
19
  import { ColorMode, Orientation } from "../renderer/pdf-config.js";
@@ -134,6 +137,9 @@ export class PDFObjectManager {
134
137
  get dests() {
135
138
  return this._dests;
136
139
  }
140
+ get acroform() {
141
+ return this._acroform;
142
+ }
137
143
  constructor(pageSize) {
138
144
  this.objects = [];
139
145
  this.objectPositions = [];
@@ -157,6 +163,11 @@ export class PDFObjectManager {
157
163
  // Per-registered face: the reserved font-object numbers + the glyph ids actually used. The font
158
164
  // program is subsetted and the objects filled in by finalizeCustomFonts(), after the render pass.
159
165
  this.customFontEmit = new Map();
166
+ // Shaped runs, keyed by font + text; `null` means "asked, and there was nothing to shape".
167
+ this.shapeCache = new Map();
168
+ // Per font, glyph -> the code points it stands for. A shaped glyph is often absent from the cmap (a
169
+ // ligature) or maps back to a presentation form, so `ToUnicode` cannot come from the cmap alone.
170
+ this.shapedOrigins = new Map();
160
171
  // Embedded file attachments (their /Filespec object numbers + display names). Referenced from
161
172
  // the catalog's /Names/EmbeddedFiles + /AF. Drives ZUGFeRD's embedded factur-x.xml.
162
173
  this.attachments = [];
@@ -167,6 +178,7 @@ export class PDFObjectManager {
167
178
  this.compress = false;
168
179
  this.overflowPolicy = "error";
169
180
  this.encJobs = [];
181
+ this.strJobs = [];
170
182
  // Accessible (PDF/UA) tagging, off by default (byte-identical output); the API turns it on.
171
183
  this._struct = new StructTree();
172
184
  // Document outline (bookmarks). Empty unless a `Bookmark` was placed; then finalize() emits /Outlines.
@@ -174,6 +186,9 @@ export class PDFObjectManager {
174
186
  // Named destinations (internal-link jump targets). Empty unless an `Anchor` was placed; then it emits
175
187
  // a /Names /Dests tree.
176
188
  this._dests = new DestRegistry();
189
+ // Interactive form fields (AcroForm). Empty unless a form field was placed; then finalize() emits the
190
+ // catalog /AcroForm dict and each field's widget lands in its page /Annots.
191
+ this._acroform = new AcroFormCollector();
177
192
  // Whether to kern. Document-level: measuring (via `runAdvance`, which reads this off the metrics)
178
193
  // and drawing (the backend emits a `TJ`) both consult it, so they can never disagree. OFF while the
179
194
  // embedded-font path (kern/GPOS) is unbuilt - a standard-14-only kerning would set the 14 system
@@ -213,6 +228,32 @@ export class PDFObjectManager {
213
228
  encToken(id) {
214
229
  return `JENC:${id}`;
215
230
  }
231
+ // The same trick for a literal STRING. A separate marker because the two are swapped differently: a
232
+ // stream keeps its delimiters and only the payload changes, a string is replaced whole by a hex one.
233
+ strToken(id) {
234
+ return `\x00\x02JSTR:${id}\x02\x00`;
235
+ }
236
+ /**
237
+ * The single choke-point every literal string in an OBJECT goes through - a field's `/T` and `/V`, a
238
+ * bookmark `/Title`, a link `/URI`, an `/Alt` text, a destination name.
239
+ *
240
+ * Without a security handler this is the escaped literal we have always written, so output stays
241
+ * byte-identical. With one, the raw bytes are registered for the finalize pass and a placeholder takes
242
+ * their place: ISO 32000-1 7.6.2 requires ALL strings and streams to be encrypted, and the ones missed
243
+ * here were exactly the values a password is supposed to protect (ISSUE-7).
244
+ *
245
+ * A string INSIDE a content stream needs none of this - the stream is enciphered as a whole.
246
+ */
247
+ pdfString(text) {
248
+ if (!this.security) {
249
+ return `(${text.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)")})`;
250
+ }
251
+ // Encrypt the bytes the unencrypted path would have written, so a viewer decrypts to the same value.
252
+ // No escaping is applied: the result is emitted as a HEX string, which has no escapes to get wrong.
253
+ const bytes = new Uint8Array(getArrayBuffer(text));
254
+ const id = this.strJobs.push(bytes) - 1;
255
+ return this.strToken(id);
256
+ }
216
257
  // The single encryption choke-point every stream emitter routes through (stream(), registerImage). Without
217
258
  // a security handler: the bytes as a latin1 string + their length. With one: register the raw bytes for the
218
259
  // finalize pass and return a placeholder + the pre-computable encrypted length (16-byte IV + PKCS#7 padding).
@@ -246,6 +287,14 @@ export class PDFObjectManager {
246
287
  addContentStream(content) {
247
288
  return this.addObject(this.stream("", new Uint8Array(getArrayBuffer(content))));
248
289
  }
290
+ // A Form XObject: a self-contained content stream with its own /BBox (+ optional /Resources). Used for
291
+ // form-field appearance streams (/AP). Routes through the same choke-point as page content, so
292
+ // compression and encryption apply. Returns the object number.
293
+ addFormXObject(bbox, content, resources) {
294
+ const dict = `/Type /XObject /Subtype /Form /BBox ${bbox}` +
295
+ (resources ? ` /Resources << ${resources} >>` : "");
296
+ return this.addObject(this.stream(dict, new Uint8Array(getArrayBuffer(content))));
297
+ }
249
298
  changePDFConfig(config) {
250
299
  this.pdfConfig = { ...this.pdfConfig, ...config };
251
300
  }
@@ -327,9 +376,8 @@ endstream`;
327
376
  attachFile(name, data, opts = {}) {
328
377
  const subtype = (opts.mimeType ?? "application/octet-stream").replace(/\//g, "#2F");
329
378
  const embedded = this.addObject(this.stream(`/Type /EmbeddedFile /Subtype /${subtype} /Params << /Size ${data.length} >>`, data));
330
- const escaped = (s) => s.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
331
- const desc = opts.description ? ` /Desc (${escaped(opts.description)})` : "";
332
- const filespec = this.addObject(`<< /Type /Filespec /F (${escaped(name)}) /UF (${escaped(name)}) ` +
379
+ const desc = opts.description ? ` /Desc ${this.pdfString(opts.description)}` : "";
380
+ const filespec = this.addObject(`<< /Type /Filespec /F ${this.pdfString(name)} /UF ${this.pdfString(name)} ` +
333
381
  `/AFRelationship /${opts.relationship ?? "Unspecified"}${desc} ` +
334
382
  `/EF << /F ${embedded} 0 R /UF ${embedded} 0 R >> >>`);
335
383
  this.attachments.push({ name, filespec });
@@ -350,8 +398,8 @@ endstream`;
350
398
  setOutputIntent(icc, opts = {}) {
351
399
  const profile = this.addObject(this.stream("/N 3", icc));
352
400
  this.outputIntent = this.addObject(`<< /Type /OutputIntent /S /GTS_PDFA1 ` +
353
- `/OutputConditionIdentifier (${opts.identifier ?? "sRGB"}) ` +
354
- `/Info (${opts.info ?? "sRGB IEC61966-2.1"}) /DestOutputProfile ${profile} 0 R >>`);
401
+ `/OutputConditionIdentifier ${this.pdfString(opts.identifier ?? "sRGB")} ` +
402
+ `/Info ${this.pdfString(opts.info ?? "sRGB IEC61966-2.1")} /DestOutputProfile ${profile} 0 R >>`);
355
403
  }
356
404
  getOutputIntent() {
357
405
  return this.outputIntent;
@@ -399,6 +447,18 @@ endstream`;
399
447
  if (idx >= 0)
400
448
  this.objects[idx] = this.objects[idx].replace(token, () => ciphertext);
401
449
  }
450
+ // Strings: the same idea, but a string is replaced WHOLE - `(text)` becomes `<ciphertext>`. Hex,
451
+ // because the ciphertext is binary and a hex string has no escape rules to get wrong.
452
+ for (let id = 0; id < this.strJobs.length; id++) {
453
+ const cipher = await this.security.encrypt(this.strJobs[id]);
454
+ const hex = Array.from(cipher, (b) => b.toString(16).padStart(2, "0").toUpperCase()).join("");
455
+ const token = this.strToken(id);
456
+ const idx = this.objects.findIndex((o) => o.includes(token));
457
+ if (idx >= 0)
458
+ this.objects[idx] = this.objects[idx].replace(token, () => `<${hex}>`);
459
+ }
460
+ // Added LAST on purpose: the /Encrypt dictionary's own strings must stay in the clear (7.6.2), and
461
+ // they would otherwise be swept up by the pass above.
402
462
  this.encryptObjNum = this.addObject(`<< ${this.security.encryptDict()} >>`);
403
463
  }
404
464
  // Registers (or reuses) a transparency graphics state and returns its resource name
@@ -505,7 +565,10 @@ endstream`;
505
565
  }
506
566
  if (byStyle.has(style))
507
567
  return;
508
- byStyle.set(style, new TTFParser(data));
568
+ // A WOFF is the same sfnt behind a small wrapper, so it is unpacked HERE - the one place every
569
+ // font path meets (a file, raw bytes, a URL, a browser upload) - and TTFParser never learns a
570
+ // second container format.
571
+ byStyle.set(style, new TTFParser(isWoff(data) ? woffToSfnt(data) : data));
509
572
  this.verticalsCache.delete(name); // this family now answers from the .ttf, not from an AFM
510
573
  this.decorationCache.delete(name);
511
574
  // Emission (the PDF font objects + page resource) is DEFERRED to first use via ensureEmitted(),
@@ -542,13 +605,13 @@ endstream`;
542
605
  // Fills the reserved font objects with a SUBSET of each font (only the glyphs the document used),
543
606
  // tagged "ABCDEF+" as PDF/A requires for subsets. Call once, after the render pass, before output.
544
607
  finalizeCustomFonts() {
545
- for (const e of this.customFontEmit.values()) {
608
+ for (const [key, e] of this.customFontEmit) {
546
609
  const ttf = e.ttf;
547
610
  const base = `${this.subsetTag(e.pdfName, e.used)}+${e.pdfName}`;
548
611
  this.replaceObject(e.fontFile, this.buildFontFile2(ttf, e.used));
549
612
  this.replaceObject(e.descriptor, this.buildFontDescriptor(base, ttf, e.fontFile));
550
613
  this.replaceObject(e.cidFont, this.buildCIDFont(base, ttf, e.descriptor, e.used));
551
- this.replaceObject(e.toUnicode, this.buildToUnicode(ttf, e.used));
614
+ this.replaceObject(e.toUnicode, this.buildToUnicode(ttf, e.used, this.shapedOrigins.get(key)));
552
615
  this.replaceObject(e.type0, this.buildType0(base, e.cidFont, e.toUnicode));
553
616
  }
554
617
  }
@@ -620,6 +683,17 @@ endstream`;
620
683
  return this.emojiImage;
621
684
  }
622
685
  // Whether `ttf` has a COLOR glyph for a code point (i.e. it would render it as color emoji).
686
+ /**
687
+ * Whether a family can draw this code point at all. An embedded face answers from its `cmap`; a
688
+ * standard-14 one can only draw what Windows-1252 encodes, which is what its `/Widths` array covers.
689
+ * Used to pick a family out of a fallback stack, per code point.
690
+ */
691
+ hasGlyph(codePoint, fontFamily, fontStyle = FontStyle.Normal) {
692
+ const ttf = this.getCustomFont(fontFamily, fontStyle);
693
+ if (ttf)
694
+ return ttf.getGlyphIndex(codePoint) !== 0;
695
+ return isWindows1252(codePoint);
696
+ }
623
697
  colorRenders(ttf, codePoint) {
624
698
  return ttf.getColorGlyph(ttf.getGlyphIndex(codePoint)) !== null;
625
699
  }
@@ -632,6 +706,53 @@ endstream`;
632
706
  this.ensureEmitted(name, resolved);
633
707
  return this.fonts.getFont(name, resolved);
634
708
  }
709
+ /** How many glyphs a run draws - its code-point count unless a ligature merged some. `letterSpacing`
710
+ * is per glyph (`Tc`), so this is what it multiplies. */
711
+ shapedGlyphCount(text, fontFamily, fontStyle) {
712
+ return this.shapeText(text, fontFamily, fontStyle)?.length;
713
+ }
714
+ /**
715
+ * The shaped glyphs of a run, or undefined when nothing needed shaping. The ONE place shaping is
716
+ * decided, so measuring and drawing cannot disagree. Memoised: a paragraph is measured several
717
+ * times (layout, pagination, drawing).
718
+ */
719
+ shapeText(text, fontFamily, fontStyle = FontStyle.Normal) {
720
+ const resolved = this.resolveCustomStyle(fontFamily, fontStyle);
721
+ if (!resolved)
722
+ return undefined;
723
+ const key = `${this.customKey(fontFamily, resolved)}\u0000${text}`;
724
+ const cached = this.shapeCache.get(key);
725
+ if (cached !== undefined)
726
+ return cached ?? undefined;
727
+ const ttf = this.customFonts.get(fontFamily).get(resolved);
728
+ const shaped = shapeRun([...text].map((c) => c.codePointAt(0)), ttf) ?? null;
729
+ this.shapeCache.set(key, shaped);
730
+ if (shaped) {
731
+ // Recorded at shaping time - the only moment the glyph and its code points are known together.
732
+ const fontKey = this.customKey(fontFamily, resolved);
733
+ let origins = this.shapedOrigins.get(fontKey);
734
+ if (!origins)
735
+ this.shapedOrigins.set(fontKey, (origins = new Map()));
736
+ for (const g of shaped)
737
+ origins.set(g.glyph, g.codePoints);
738
+ }
739
+ return shaped ?? undefined;
740
+ }
741
+ /** Hex Identity-H for glyphs the producer already resolved (a shaped run), which cannot be given as
742
+ * text. Recording each keeps it in the subset. */
743
+ registerGlyphs(name, glyphs, style = FontStyle.Normal) {
744
+ const resolved = this.resolveCustomStyle(name, style);
745
+ if (!resolved)
746
+ return "";
747
+ this.ensureEmitted(name, resolved);
748
+ const emit = this.customFontEmit.get(this.customKey(name, resolved));
749
+ let out = "";
750
+ for (const g of glyphs) {
751
+ emit.used.add(g);
752
+ out += g.toString(16).padStart(4, "0").toUpperCase();
753
+ }
754
+ return out;
755
+ }
635
756
  // Encodes text as a hex Identity-H string for an embedded font's Tj operator: each codepoint
636
757
  // becomes its 2-byte glyph id (CID == GID under /CIDToGIDMap /Identity).
637
758
  encodeCustomText(name, text, style = FontStyle.Normal) {
@@ -642,11 +763,22 @@ endstream`;
642
763
  // Once per text run, not per character: the flat key is fine here.
643
764
  const emit = this.customFontEmit.get(this.customKey(name, resolved));
644
765
  const { ttf, used } = emit;
766
+ const hex4 = (gid) => gid.toString(16).padStart(4, "0").toUpperCase();
767
+ // The SAME shaped run the measuring path used, so the glyphs drawn are the glyphs measured.
768
+ const shaped = this.shapeText(text, name, style);
769
+ if (shaped) {
770
+ let out = "";
771
+ for (const g of shaped) {
772
+ used.add(g.glyph);
773
+ out += hex4(g.glyph);
774
+ }
775
+ return out;
776
+ }
645
777
  let hex = "";
646
778
  for (const ch of text) {
647
779
  const gid = ttf.getGlyphIndex(ch.codePointAt(0));
648
780
  used.add(gid); // record the glyph so the subset keeps it
649
- hex += gid.toString(16).padStart(4, "0").toUpperCase();
781
+ hex += hex4(gid);
650
782
  }
651
783
  return hex;
652
784
  }
@@ -673,23 +805,30 @@ endstream`;
673
805
  .map((g) => `${g} [${all[g] ?? 1000}]`)
674
806
  .join(" ");
675
807
  return (`<< /Type /Font /Subtype /CIDFontType2 /BaseFont /${pdfName(name)} ` +
676
- `/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> ` +
808
+ `/CIDSystemInfo << /Registry ${this.pdfString("Adobe")} /Ordering ${this.pdfString("Identity")} /Supplement 0 >> ` +
677
809
  `/FontDescriptor ${descriptor} 0 R /CIDToGIDMap /Identity ` +
678
810
  `/DW 1000 /W [${w}] >>`);
679
811
  }
680
812
  // Maps glyph id -> Unicode so the text stays copy-/searchable (rendering doesn't need it). Only
681
813
  // the used glyphs are listed, to match the subset.
682
- buildToUnicode(ttf, used) {
814
+ buildToUnicode(ttf, used, shapedOrigins = new Map()) {
683
815
  const hex4 = (n) => n.toString(16).padStart(4, "0").toUpperCase();
816
+ // A code point above the BMP needs its surrogate PAIR here - a `bfchar` destination is UTF-16BE.
817
+ const utf16 = (code) => code > 0xffff
818
+ ? hex4(0xd800 + ((code - 0x10000) >> 10)) + hex4(0xdc00 + ((code - 0x10000) & 0x3ff))
819
+ : hex4(code);
684
820
  const rev = ttf.reverseCmap();
821
+ // A SHAPED glyph wins: it is often absent from the cmap (a ligature) or maps back to a
822
+ // presentation form rather than the letter that was written (a joining form), so the reverse
823
+ // cmap alone would make copied text wrong.
685
824
  const entries = [...used]
686
825
  .sort((a, b) => a - b)
687
- .filter((g) => rev.has(g))
688
- .map((g) => [g, rev.get(g)]);
826
+ .filter((g) => shapedOrigins.has(g) || rev.has(g))
827
+ .map((g) => [g, shapedOrigins.get(g) ?? [rev.get(g)]]);
689
828
  const blocks = [];
690
829
  for (let i = 0; i < entries.length; i += 100) {
691
830
  const block = entries.slice(i, i + 100);
692
- const lines = block.map(([gid, code]) => `<${hex4(gid)}> <${hex4(code)}>`);
831
+ const lines = block.map(([gid, codes]) => `<${hex4(gid)}> <${codes.map(utf16).join("")}>`);
693
832
  blocks.push(`${block.length} beginbfchar\n${lines.join("\n")}\nendbfchar`);
694
833
  }
695
834
  const body = `/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n` +
@@ -765,6 +904,11 @@ endstream`;
765
904
  const chars = [...text];
766
905
  if (chars.length < 2)
767
906
  return [];
907
+ // A shaped run does not kern here: these pairs are keyed by the UNSHAPED glyphs, and the `TJ`
908
+ // path would re-shape each chunk on its own, turning every letter isolated. Arabic kerning is in
909
+ // GPOS, applied during shaping - not built (`todo.md`).
910
+ if (this.shapeText(text, fontFamily, fontStyle))
911
+ return [];
768
912
  const out = [];
769
913
  // An embedded font kerns by GLYPH id (kern table / GPOS); a standard-14 one by character (AFM).
770
914
  const ttf = this.getCustomFont(fontFamily, fontStyle);
@@ -816,6 +960,13 @@ endstream`;
816
960
  }
817
961
  // Returns the current width of a text, included kernings
818
962
  getStringWidth(text, fontFamily, fontSize, fontStyle) {
963
+ // Shaping first: a joined run is narrower than the same letters apart, so measuring the unshaped
964
+ // form while drawing the shaped one would break every line. Both sides ask `shapeText`.
965
+ const shaped = this.shapeText(text, fontFamily, fontStyle);
966
+ if (shaped) {
967
+ const ttf = this.getCustomFont(fontFamily, fontStyle);
968
+ return shaped.reduce((w, g) => w + ttf.unitsToPoints(g.advance, fontSize), 0);
969
+ }
819
970
  let width = 0;
820
971
  // Iterate code points, not UTF-16 units: an astral char (emoji, CJK-ext) is a surrogate pair,
821
972
  // and indexing by unit would measure each half as its own (zero-width) "char". The spread splits
@@ -1,5 +1,4 @@
1
1
  // Escapes a PDF literal string ( ... ) - the language tag is safe ASCII, but /Alt can carry arbitrary text.
2
- const esc = (s) => s.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
3
2
  // A structure role reaches the PDF as a raw name (/H1, /S /Table). Roles come from a controlled set,
4
3
  // but restrict them to a safe PDF-name token anyway (defence-in-depth) so a stray "/", whitespace, or
5
4
  // operator can never corrupt the content stream / object dictionary. Empty after stripping -> /Span.
@@ -90,7 +89,7 @@ export class StructTree {
90
89
  ...childrenOf(e.key).map((c) => `${objNum.get(c.key)} 0 R`),
91
90
  ...e.mc.map((m) => `<< /Type /MCR /Pg ${pg(m.structParents)} 0 R /MCID ${m.mcid} >>`),
92
91
  ].join(" ");
93
- const alt = e.alt ? ` /Alt (${esc(e.alt)})` : "";
92
+ const alt = e.alt ? ` /Alt ${om.pdfString(e.alt)}` : "";
94
93
  // PDF/UA 7.5: a header cell needs a Scope so a reader can associate data cells with it. Our headers
95
94
  // are a header ROW (the Table `header` option), so each TH is the head of its Column.
96
95
  const attr = e.role === "TH" ? " /A << /O /Table /Scope /Column >>" : "";
@@ -125,6 +124,6 @@ export class StructTree {
125
124
  om.replaceObject(parentTreeNum, `<< /Nums [ ${nums.join(" ")} ] >>`);
126
125
  om.replaceObject(rootNum, `<< /Type /StructTreeRoot /K ${docNum} 0 R /ParentTree ${parentTreeNum} 0 R ` +
127
126
  `/ParentTreeNextKey ${this.structParentsCounter} >>`);
128
- return `/MarkInfo << /Marked true >> /StructTreeRoot ${rootNum} 0 R /Lang (${esc(this.lang)})`;
127
+ return `/MarkInfo << /Marked true >> /StructTreeRoot ${rootNum} 0 R /Lang ${om.pdfString(this.lang)}`;
129
128
  }
130
129
  }
@@ -1,4 +1,5 @@
1
1
  import type { FontDecoration } from "../text/text-decoration.js";
2
+ import { GsubTable } from "./gsub.js";
2
3
  export type GlyphPathCommand = {
3
4
  type: "M";
4
5
  x: number;
@@ -77,14 +78,19 @@ export declare class TTFParser {
77
78
  private kernPairs;
78
79
  private hasKerning;
79
80
  private gposPairPos;
81
+ private gsubTable?;
80
82
  constructor(data: Uint8Array);
81
83
  getData(): Uint8Array;
82
84
  glyphCount(): number;
83
85
  glyphWidths(): number[];
84
86
  reverseCmap(): Map<number, number>;
85
87
  private toGlyphSpace;
88
+ /** The font's `GSUB`, or undefined when it has none. Parsed once, on demand. */
89
+ gsub(): GsubTable | undefined;
86
90
  getGlyphIndex(codePoint: number): number;
87
91
  getAdvanceWidth(glyphIndex: number): number;
92
+ /** Font units -> points at `fontSize`; the one scaling rule for shaped and plain advances alike. */
93
+ unitsToPoints(units: number, fontSize: number): number;
88
94
  getCharWidth(char: string, fontSize: number): number;
89
95
  getStringWidth(text: string, fontSize: number): number;
90
96
  private readDecorationMetrics;
@@ -2,6 +2,7 @@
2
2
  // Slice 1: glyph advance widths (hmtx) + the Unicode→glyph map (cmap), enough to compute
3
3
  // text width the same way AFMParser does for the standard-14. Embedding/subsetting come later.
4
4
  import { i16, latin1FromBytes, u8, u16, u32 } from "./bytes.js";
5
+ import { GsubTable } from "./gsub.js";
5
6
  const IDENTITY = [1, 0, 0, 1, 0, 0];
6
7
  /** Sorts x-intervals and unions the overlapping ones. */
7
8
  export function mergeSpans(spans) {
@@ -151,6 +152,20 @@ export class TTFParser {
151
152
  toGlyphSpace(v) {
152
153
  return Math.round((v * 1000) / this.unitsPerEm);
153
154
  }
155
+ /** The font's `GSUB`, or undefined when it has none. Parsed once, on demand. */
156
+ gsub() {
157
+ if (this.gsubTable === undefined) {
158
+ const record = this.tables["GSUB"];
159
+ // Degrade to "no shaping" rather than break font loading - same rule as kern.
160
+ try {
161
+ this.gsubTable = record ? new GsubTable(this.data, record.offset) : null;
162
+ }
163
+ catch {
164
+ this.gsubTable = null;
165
+ }
166
+ }
167
+ return this.gsubTable ?? undefined;
168
+ }
154
169
  // Codepoint → glyph id (0 = .notdef when the font has no glyph for it).
155
170
  getGlyphIndex(codePoint) {
156
171
  const g = this.cmap.get(codePoint);
@@ -169,6 +184,10 @@ export class TTFParser {
169
184
  return this.advanceWidths[i] ?? 0;
170
185
  }
171
186
  // Char width at fontSize, scaled from font units (em = unitsPerEm) to points.
187
+ /** Font units -> points at `fontSize`; the one scaling rule for shaped and plain advances alike. */
188
+ unitsToPoints(units, fontSize) {
189
+ return (units / this.unitsPerEm) * fontSize;
190
+ }
172
191
  getCharWidth(char, fontSize) {
173
192
  const units = this.getAdvanceWidth(this.getGlyphIndex(char.codePointAt(0)));
174
193
  return (units / this.unitsPerEm) * fontSize;
@@ -1,2 +1,8 @@
1
+ /**
2
+ * Whether Windows-1252 can carry this code point - which is exactly what a standard-14 font can draw,
3
+ * since that is the encoding its `/Widths` array is indexed by. Latin-1 passes straight through,
4
+ * except the C1 range, which the table above fills with printable punctuation instead.
5
+ */
6
+ export declare function isWindows1252(codePoint: number): boolean;
1
7
  /** Encodes a JavaScript string to a Windows-1252 byte buffer (the PDF text encoding). */
2
8
  export declare function getArrayBuffer(data: string): ArrayBuffer;
@@ -33,6 +33,16 @@ const CP1252_FROM_UNICODE = {
33
33
  0x017e: 0x9e, // ž z caron
34
34
  0x0178: 0x9f, // Ÿ Y diaeresis
35
35
  };
36
+ /**
37
+ * Whether Windows-1252 can carry this code point - which is exactly what a standard-14 font can draw,
38
+ * since that is the encoding its `/Widths` array is indexed by. Latin-1 passes straight through,
39
+ * except the C1 range, which the table above fills with printable punctuation instead.
40
+ */
41
+ export function isWindows1252(codePoint) {
42
+ if (codePoint in CP1252_FROM_UNICODE)
43
+ return true;
44
+ return codePoint <= 0xff && !(codePoint >= 0x80 && codePoint <= 0x9f);
45
+ }
36
46
  /** Encodes a JavaScript string to a Windows-1252 byte buffer (the PDF text encoding). */
37
47
  export function getArrayBuffer(data) {
38
48
  const u8 = new Uint8Array(data.length);
@@ -0,0 +1,10 @@
1
+ export declare class WoffError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ /** Whether these bytes are a WOFF 1.0 container. Cheap enough to call on every font. */
5
+ export declare const isWoff: (bytes: Uint8Array) => boolean;
6
+ /**
7
+ * Unpack a WOFF into the sfnt it wraps. The result is byte-for-byte what a `.ttf` of the same font
8
+ * would be, apart from table padding, so nothing downstream can tell the difference.
9
+ */
10
+ export declare function woffToSfnt(woff: Uint8Array): Uint8Array;
@@ -0,0 +1,116 @@
1
+ import { inflateBounded } from "./inflate.js";
2
+ /**
3
+ * WOFF 1.0 -> plain sfnt, so the existing `TTFParser` never learns a second container format.
4
+ *
5
+ * A WOFF is not a different FONT, it is a different wrapper: the same sfnt tables, each optionally
6
+ * zlib-compressed, behind a small header. Unpacking it back into an sfnt is therefore the whole job -
7
+ * every metric, glyph and cmap path downstream stays exactly as it was.
8
+ *
9
+ * WOFF2 is deliberately NOT here: it compresses with Brotli, which `fflate` does not do, and it also
10
+ * TRANSFORMS the glyf/loca tables rather than merely deflating them. That is a different piece of work,
11
+ * not a bigger version of this one.
12
+ */
13
+ const SIG_WOFF = 0x774f4646; // "wOFF"
14
+ const HEADER = 44;
15
+ const DIR_ENTRY = 20;
16
+ /** One table may not inflate past this. A single sfnt table above 64 MB is not a real font. */
17
+ const MAX_TABLE_BYTES = 64 * 1024 * 1024;
18
+ /**
19
+ * And all of them together may not either. The per-table ceiling alone is not a bound: `numTables` is a
20
+ * uint16, so 65535 directory entries may all point at the SAME small compressed blob, and each would be
21
+ * inflated and kept. Generous enough for a full CJK face, which is the largest real font there is.
22
+ */
23
+ const MAX_TOTAL_BYTES = 64 * 1024 * 1024;
24
+ export class WoffError extends Error {
25
+ constructor(message) {
26
+ super(`@jasy/pdf: ${message}`);
27
+ }
28
+ }
29
+ const be32 = (b, at) => ((b[at] << 24) | (b[at + 1] << 16) | (b[at + 2] << 8) | b[at + 3]) >>> 0;
30
+ const be16 = (b, at) => (b[at] << 8) | b[at + 1];
31
+ /** Whether these bytes are a WOFF 1.0 container. Cheap enough to call on every font. */
32
+ export const isWoff = (bytes) => bytes.length >= HEADER && be32(bytes, 0) === SIG_WOFF;
33
+ /**
34
+ * Unpack a WOFF into the sfnt it wraps. The result is byte-for-byte what a `.ttf` of the same font
35
+ * would be, apart from table padding, so nothing downstream can tell the difference.
36
+ */
37
+ export function woffToSfnt(woff) {
38
+ if (!isWoff(woff))
39
+ throw new WoffError("these bytes are not a WOFF container");
40
+ const flavor = be32(woff, 4);
41
+ const numTables = be16(woff, 12);
42
+ if (numTables === 0)
43
+ throw new WoffError("this WOFF declares no tables");
44
+ if (woff.length < HEADER + numTables * DIR_ENTRY) {
45
+ throw new WoffError("this WOFF is truncated: its table directory does not fit in the file");
46
+ }
47
+ // Read the directory first, then the tables, so a malformed offset is caught before any allocation.
48
+ const entries = Array.from({ length: numTables }, (_, i) => {
49
+ const at = HEADER + i * DIR_ENTRY;
50
+ return {
51
+ tag: be32(woff, at),
52
+ offset: be32(woff, at + 4),
53
+ compLength: be32(woff, at + 8),
54
+ origLength: be32(woff, at + 12),
55
+ checksum: be32(woff, at + 16),
56
+ };
57
+ });
58
+ // Tracked across ALL tables, and checked BEFORE each inflate so a hostile directory costs neither the
59
+ // CPU of unpacking nor the memory of keeping the result.
60
+ let unpacked = 0;
61
+ const claim = (bytes) => {
62
+ unpacked += bytes;
63
+ if (unpacked > MAX_TOTAL_BYTES) {
64
+ throw new WoffError(`this WOFF unpacks to more than ${MAX_TOTAL_BYTES} bytes in total; no real font is that large`);
65
+ }
66
+ };
67
+ const tables = entries.map((e) => {
68
+ if (e.offset + e.compLength > woff.length) {
69
+ throw new WoffError(`this WOFF is truncated: a table runs past the end of the file`);
70
+ }
71
+ claim(e.origLength); // what it DECLARES, before any work is done on it
72
+ const raw = woff.subarray(e.offset, e.offset + e.compLength);
73
+ // Equal lengths mean the table was stored as-is; anything else is zlib, per the spec.
74
+ if (e.compLength === e.origLength)
75
+ return raw;
76
+ let out;
77
+ try {
78
+ out = inflateBounded(raw, MAX_TABLE_BYTES);
79
+ }
80
+ catch (err) {
81
+ throw new WoffError(`a table in this WOFF could not be unpacked: ${String(err?.message ?? err)}`);
82
+ }
83
+ if (out.length !== e.origLength) {
84
+ throw new WoffError(`a table in this WOFF unpacked to ${out.length} bytes where it declared ${e.origLength}`);
85
+ }
86
+ return out;
87
+ });
88
+ // Reassemble: offset table, directory, then the tables each padded to a 4-byte boundary.
89
+ const padded = (n) => (n + 3) & ~3;
90
+ const dirSize = numTables * 16;
91
+ const total = 12 + dirSize + tables.reduce((n, t) => n + padded(t.length), 0);
92
+ const out = new Uint8Array(total);
93
+ const view = new DataView(out.buffer);
94
+ // The binary-search hints an sfnt offset table carries. Readers may ignore them; a correct file has
95
+ // them right, and getting them wrong is the classic way a "valid" font is rejected by a strict one.
96
+ const entrySelector = Math.floor(Math.log2(numTables));
97
+ const searchRange = 2 ** entrySelector * 16;
98
+ view.setUint32(0, flavor);
99
+ view.setUint16(4, numTables);
100
+ view.setUint16(6, searchRange);
101
+ view.setUint16(8, entrySelector);
102
+ view.setUint16(10, numTables * 16 - searchRange);
103
+ let dir = 12;
104
+ let body = 12 + dirSize;
105
+ entries.forEach((e, i) => {
106
+ const data = tables[i];
107
+ view.setUint32(dir, e.tag);
108
+ view.setUint32(dir + 4, e.checksum);
109
+ view.setUint32(dir + 8, body);
110
+ view.setUint32(dir + 12, data.length); // the DECLARED length excludes the padding
111
+ out.set(data, body);
112
+ dir += 16;
113
+ body += padded(data.length);
114
+ });
115
+ return out;
116
+ }
@@ -41,9 +41,20 @@ export class Validator {
41
41
  }
42
42
  }
43
43
  static validateFlexElement(element) {
44
- // Ensure flexible elements have valid flex values
45
- if (element.getFlex() <= 0) {
46
- throw new Error(`Flexible element ${element.constructor.name} has invalid flex value`);
44
+ // Ensure flexible elements have valid flex values. The message names what the CALLER wrote, not the
45
+ // element class: `Spacer` and `Expanded` are what exists in their document, `ExpandedElement` is not.
46
+ const flex = element.getFlex();
47
+ // `flex: 0` used to be meaningless - claiming no share is what leaving the element out does. With a
48
+ // `flexBasis` it is not: the child is then exactly its basis, a fixed slot that still participates
49
+ // in the line. So the rule now reads "claim SOMETHING": a share, a basis, or both.
50
+ // A percentage basis resolves to 0 on an unbounded axis, so it is measured against a finite
51
+ // reference instead - otherwise `flexBasis: "0%"` would claim nothing and still pass.
52
+ const claimsSpace = element.getBasis(Infinity) > 0 || element.getBasis(1) > 0;
53
+ if (flex < 0 || (flex === 0 && !claimsSpace)) {
54
+ throw new Error(`@jasy/pdf: Spacer/Expanded needs a flex above 0, got ${flex}. Flex is a SHARE of the leftover ` +
55
+ "space, so 0 would claim none - which is what leaving the element out does. For a fixed gap " +
56
+ "use the `gap` of the surrounding Column or Row, or a Box with a height. (A `flexBasis` " +
57
+ "makes `flex: 0` meaningful: the child is then exactly its basis.)");
47
58
  }
48
59
  // Ensure a flexible element does not contain another flexible element
49
60
  if (hasChildProp(element)) {
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@jasy/pdf",
3
- "version": "1.0.0-alpha.7",
3
+ "version": "1.0.0-alpha.9",
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",
7
+ "e-invoice",
7
8
  "factur-x",
8
9
  "invoice",
9
10
  "layout",
@@ -12,6 +13,7 @@
12
13
  "pdf-a",
13
14
  "pdf-generation",
14
15
  "typescript",
16
+ "xrechnung",
15
17
  "zugferd"
16
18
  ],
17
19
  "license": "MIT",
@@ -30,9 +32,15 @@
30
32
  ".": {
31
33
  "types": "./dist/index.d.ts",
32
34
  "default": "./dist/index.js"
33
- }
35
+ },
36
+ "./edit": {
37
+ "types": "./dist/edit/index.d.ts",
38
+ "default": "./dist/edit/index.js"
39
+ },
40
+ "./package.json": "./package.json"
34
41
  },
35
42
  "dependencies": {
43
+ "bidi-js": "^1.0.3",
36
44
  "fflate": "^0.8.3",
37
45
  "jimp": "^1.6.0"
38
46
  },