@jasy/pdf 1.0.0-alpha.8 → 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 (97) hide show
  1. package/README.md +4 -3
  2. package/dist/api/content.d.ts +5 -4
  3. package/dist/api/content.js +13 -7
  4. package/dist/api/dimension.d.ts +56 -0
  5. package/dist/api/dimension.js +27 -0
  6. package/dist/api/font-url.d.ts +28 -0
  7. package/dist/api/font-url.js +134 -0
  8. package/dist/api/gradient.d.ts +53 -0
  9. package/dist/api/gradient.js +97 -0
  10. package/dist/api/index.d.ts +2 -0
  11. package/dist/api/index.js +2 -0
  12. package/dist/api/insets.d.ts +19 -12
  13. package/dist/api/insets.js +18 -7
  14. package/dist/api/layout.d.ts +20 -6
  15. package/dist/api/layout.js +31 -6
  16. package/dist/api/page-builder.d.ts +8 -0
  17. package/dist/api/page-builder.js +12 -0
  18. package/dist/api/structure.d.ts +5 -0
  19. package/dist/api/structure.js +16 -4
  20. package/dist/api/text.d.ts +42 -6
  21. package/dist/api/text.js +29 -5
  22. package/dist/edit/document.d.ts +14 -4
  23. package/dist/edit/document.js +15 -22
  24. package/dist/edit/fill.d.ts +19 -2
  25. package/dist/edit/fill.js +63 -34
  26. package/dist/edit/flatten.d.ts +13 -1
  27. package/dist/edit/flatten.js +38 -13
  28. package/dist/edit/index.d.ts +2 -0
  29. package/dist/edit/index.js +3 -1
  30. package/dist/edit/objects.d.ts +5 -0
  31. package/dist/edit/objects.js +27 -0
  32. package/dist/edit/writer.d.ts +7 -0
  33. package/dist/edit/writer.js +23 -1
  34. package/dist/elements/container-element.d.ts +21 -1
  35. package/dist/elements/container-element.js +25 -10
  36. package/dist/elements/image-element.d.ts +18 -4
  37. package/dist/elements/image-element.js +27 -12
  38. package/dist/elements/layout/expanded-element.d.ts +3 -1
  39. package/dist/elements/layout/expanded-element.js +12 -3
  40. package/dist/elements/layout/padding-element.d.ts +4 -2
  41. package/dist/elements/layout/padding-element.js +4 -2
  42. package/dist/elements/layout/page-builder-element.js +2 -0
  43. package/dist/elements/pdf-element.d.ts +59 -1
  44. package/dist/elements/pdf-element.js +62 -1
  45. package/dist/elements/rectangle-element.d.ts +20 -6
  46. package/dist/elements/rectangle-element.js +25 -12
  47. package/dist/elements/row-element.d.ts +24 -1
  48. package/dist/elements/row-element.js +25 -10
  49. package/dist/elements/text-element.d.ts +60 -1
  50. package/dist/elements/text-element.js +132 -33
  51. package/dist/ir/display-list.d.ts +23 -6
  52. package/dist/ir/display-list.js +8 -1
  53. package/dist/layout/alignment.d.ts +10 -0
  54. package/dist/layout/alignment.js +6 -0
  55. package/dist/layout/box-constraints.d.ts +53 -0
  56. package/dist/layout/box-constraints.js +72 -0
  57. package/dist/layout/insets.d.ts +28 -0
  58. package/dist/layout/insets.js +20 -0
  59. package/dist/renderer/image-renderer.js +2 -1
  60. package/dist/renderer/pdf-backend.d.ts +6 -0
  61. package/dist/renderer/pdf-backend.js +78 -31
  62. package/dist/renderer/pdf-document-renderer.js +8 -1
  63. package/dist/renderer/rectangle-renderer.js +8 -2
  64. package/dist/renderer/text-renderer.d.ts +2 -2
  65. package/dist/renderer/text-renderer.js +150 -42
  66. package/dist/text/advance.js +5 -2
  67. package/dist/text/arabic.d.ts +20 -0
  68. package/dist/text/arabic.js +101 -0
  69. package/dist/text/bidi.d.ts +33 -0
  70. package/dist/text/bidi.js +94 -0
  71. package/dist/text/font-fallback.d.ts +25 -0
  72. package/dist/text/font-fallback.js +38 -0
  73. package/dist/text/line-breaker.d.ts +35 -1
  74. package/dist/text/line-breaker.js +76 -16
  75. package/dist/text/orphans-widows.d.ts +27 -0
  76. package/dist/text/orphans-widows.js +53 -0
  77. package/dist/text/shape.d.ts +29 -0
  78. package/dist/text/shape.js +67 -0
  79. package/dist/text/text-style.d.ts +25 -0
  80. package/dist/text/text-style.js +44 -1
  81. package/dist/utils/flex-layout.d.ts +24 -4
  82. package/dist/utils/flex-layout.js +169 -7
  83. package/dist/utils/font-metrics.d.ts +5 -0
  84. package/dist/utils/gsub.d.ts +36 -0
  85. package/dist/utils/gsub.js +178 -0
  86. package/dist/utils/inflate.d.ts +16 -0
  87. package/dist/utils/inflate.js +52 -0
  88. package/dist/utils/pdf-object-manager.d.ts +21 -0
  89. package/dist/utils/pdf-object-manager.js +107 -9
  90. package/dist/utils/ttf-parser.d.ts +6 -0
  91. package/dist/utils/ttf-parser.js +19 -0
  92. package/dist/utils/utf8-to-windows1252-encoder.d.ts +6 -0
  93. package/dist/utils/utf8-to-windows1252-encoder.js +10 -0
  94. package/dist/utils/woff.d.ts +10 -0
  95. package/dist/utils/woff.js +116 -0
  96. package/dist/validators/element-validator.js +9 -2
  97. package/package.json +2 -1
@@ -5,8 +5,10 @@ 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";
@@ -161,6 +163,11 @@ export class PDFObjectManager {
161
163
  // Per-registered face: the reserved font-object numbers + the glyph ids actually used. The font
162
164
  // program is subsetted and the objects filled in by finalizeCustomFonts(), after the render pass.
163
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();
164
171
  // Embedded file attachments (their /Filespec object numbers + display names). Referenced from
165
172
  // the catalog's /Names/EmbeddedFiles + /AF. Drives ZUGFeRD's embedded factur-x.xml.
166
173
  this.attachments = [];
@@ -558,7 +565,10 @@ endstream`;
558
565
  }
559
566
  if (byStyle.has(style))
560
567
  return;
561
- 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));
562
572
  this.verticalsCache.delete(name); // this family now answers from the .ttf, not from an AFM
563
573
  this.decorationCache.delete(name);
564
574
  // Emission (the PDF font objects + page resource) is DEFERRED to first use via ensureEmitted(),
@@ -595,13 +605,13 @@ endstream`;
595
605
  // Fills the reserved font objects with a SUBSET of each font (only the glyphs the document used),
596
606
  // tagged "ABCDEF+" as PDF/A requires for subsets. Call once, after the render pass, before output.
597
607
  finalizeCustomFonts() {
598
- for (const e of this.customFontEmit.values()) {
608
+ for (const [key, e] of this.customFontEmit) {
599
609
  const ttf = e.ttf;
600
610
  const base = `${this.subsetTag(e.pdfName, e.used)}+${e.pdfName}`;
601
611
  this.replaceObject(e.fontFile, this.buildFontFile2(ttf, e.used));
602
612
  this.replaceObject(e.descriptor, this.buildFontDescriptor(base, ttf, e.fontFile));
603
613
  this.replaceObject(e.cidFont, this.buildCIDFont(base, ttf, e.descriptor, e.used));
604
- this.replaceObject(e.toUnicode, this.buildToUnicode(ttf, e.used));
614
+ this.replaceObject(e.toUnicode, this.buildToUnicode(ttf, e.used, this.shapedOrigins.get(key)));
605
615
  this.replaceObject(e.type0, this.buildType0(base, e.cidFont, e.toUnicode));
606
616
  }
607
617
  }
@@ -673,6 +683,17 @@ endstream`;
673
683
  return this.emojiImage;
674
684
  }
675
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
+ }
676
697
  colorRenders(ttf, codePoint) {
677
698
  return ttf.getColorGlyph(ttf.getGlyphIndex(codePoint)) !== null;
678
699
  }
@@ -685,6 +706,53 @@ endstream`;
685
706
  this.ensureEmitted(name, resolved);
686
707
  return this.fonts.getFont(name, resolved);
687
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
+ }
688
756
  // Encodes text as a hex Identity-H string for an embedded font's Tj operator: each codepoint
689
757
  // becomes its 2-byte glyph id (CID == GID under /CIDToGIDMap /Identity).
690
758
  encodeCustomText(name, text, style = FontStyle.Normal) {
@@ -695,11 +763,22 @@ endstream`;
695
763
  // Once per text run, not per character: the flat key is fine here.
696
764
  const emit = this.customFontEmit.get(this.customKey(name, resolved));
697
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
+ }
698
777
  let hex = "";
699
778
  for (const ch of text) {
700
779
  const gid = ttf.getGlyphIndex(ch.codePointAt(0));
701
780
  used.add(gid); // record the glyph so the subset keeps it
702
- hex += gid.toString(16).padStart(4, "0").toUpperCase();
781
+ hex += hex4(gid);
703
782
  }
704
783
  return hex;
705
784
  }
@@ -732,17 +811,24 @@ endstream`;
732
811
  }
733
812
  // Maps glyph id -> Unicode so the text stays copy-/searchable (rendering doesn't need it). Only
734
813
  // the used glyphs are listed, to match the subset.
735
- buildToUnicode(ttf, used) {
814
+ buildToUnicode(ttf, used, shapedOrigins = new Map()) {
736
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);
737
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.
738
824
  const entries = [...used]
739
825
  .sort((a, b) => a - b)
740
- .filter((g) => rev.has(g))
741
- .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)]]);
742
828
  const blocks = [];
743
829
  for (let i = 0; i < entries.length; i += 100) {
744
830
  const block = entries.slice(i, i + 100);
745
- const lines = block.map(([gid, code]) => `<${hex4(gid)}> <${hex4(code)}>`);
831
+ const lines = block.map(([gid, codes]) => `<${hex4(gid)}> <${codes.map(utf16).join("")}>`);
746
832
  blocks.push(`${block.length} beginbfchar\n${lines.join("\n")}\nendbfchar`);
747
833
  }
748
834
  const body = `/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n` +
@@ -818,6 +904,11 @@ endstream`;
818
904
  const chars = [...text];
819
905
  if (chars.length < 2)
820
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 [];
821
912
  const out = [];
822
913
  // An embedded font kerns by GLYPH id (kern table / GPOS); a standard-14 one by character (AFM).
823
914
  const ttf = this.getCustomFont(fontFamily, fontStyle);
@@ -869,6 +960,13 @@ endstream`;
869
960
  }
870
961
  // Returns the current width of a text, included kernings
871
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
+ }
872
970
  let width = 0;
873
971
  // Iterate code points, not UTF-16 units: an astral char (emoji, CJK-ext) is a surrogate pair,
874
972
  // and indexing by unit would measure each half as its own (zero-width) "char". The spread splits
@@ -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
+ }
@@ -44,10 +44,17 @@ export class Validator {
44
44
  // Ensure flexible elements have valid flex values. The message names what the CALLER wrote, not the
45
45
  // element class: `Spacer` and `Expanded` are what exists in their document, `ExpandedElement` is not.
46
46
  const flex = element.getFlex();
47
- if (flex <= 0) {
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)) {
48
54
  throw new Error(`@jasy/pdf: Spacer/Expanded needs a flex above 0, got ${flex}. Flex is a SHARE of the leftover ` +
49
55
  "space, so 0 would claim none - which is what leaving the element out does. For a fixed gap " +
50
- "use the `gap` of the surrounding Column or Row, or a Box with a height.");
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.)");
51
58
  }
52
59
  // Ensure a flexible element does not contain another flexible element
53
60
  if (hasChildProp(element)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jasy/pdf",
3
- "version": "1.0.0-alpha.8",
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",
@@ -40,6 +40,7 @@
40
40
  "./package.json": "./package.json"
41
41
  },
42
42
  "dependencies": {
43
+ "bidi-js": "^1.0.3",
43
44
  "fflate": "^0.8.3",
44
45
  "jimp": "^1.6.0"
45
46
  },