reamkit 1.28.0 → 1.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,6 +29,14 @@ export interface ReamParseOptions {
29
29
  * carrying one for every filter anyone might write:
30
30
  *
31
31
  * ```ts
32
+ * // browser: any wasm/JS decoder you ship — brotli-dec-wasm is ~200 KB
33
+ * import brotliPromise from 'brotli-dec-wasm';
34
+ * const brotli = await brotliPromise;
35
+ * Ream.parse(pdf, { filters: { BrotliDecode: (b) => brotli.decompress(b) } });
36
+ * ```
37
+ *
38
+ * ```ts
39
+ * // node, where the runtime already carries one
32
40
  * import { brotliDecompressSync } from 'node:zlib';
33
41
  * Ream.parse(pdf, { filters: { BrotliDecode: (b) => brotliDecompressSync(b) } });
34
42
  * ```
@@ -32,7 +32,8 @@ function parseToUnicodeCMap(bytes) {
32
32
  if (src.kind !== "hexstr") continue;
33
33
  const dst = lexer.nextToken();
34
34
  if (dst.kind !== "hexstr") continue;
35
- map.set(bytesToInt(src.bytes), utf16be(dst.bytes));
35
+ const text = stated(utf16be(dst.bytes));
36
+ if (text !== void 0) map.set(bytesToInt(src.bytes), text);
36
37
  }
37
38
  else if (tok.value === "beginbfrange") for (;;) {
38
39
  const lo = lexer.nextToken();
@@ -44,14 +45,17 @@ function parseToUnicodeCMap(bytes) {
44
45
  const hiN = bytesToInt(hi.bytes);
45
46
  const dst = lexer.nextToken();
46
47
  if (dst.kind === "hexstr") {
47
- const base = utf16be(dst.bytes);
48
- for (let i = 0; loN + i <= hiN && i < MAX_RANGE; i++) map.set(loN + i, incString(base, i));
48
+ const base = stated(utf16be(dst.bytes));
49
+ if (base !== void 0) for (let i = 0; loN + i <= hiN && i < MAX_RANGE; i++) map.set(loN + i, incString(base, i));
49
50
  } else if (dst.kind === "arrayOpen") {
50
51
  let i = 0;
51
52
  for (;;) {
52
53
  const el = lexer.nextToken();
53
54
  if (el.kind === "arrayClose" || el.kind === "eof") break;
54
- if (el.kind === "hexstr") map.set(loN + i++, utf16be(el.bytes));
55
+ if (el.kind !== "hexstr") continue;
56
+ const text = stated(utf16be(el.bytes));
57
+ if (text !== void 0) map.set(loN + i, text);
58
+ i++;
55
59
  }
56
60
  }
57
61
  }
@@ -61,6 +65,9 @@ function parseToUnicodeCMap(bytes) {
61
65
  codeBytes
62
66
  };
63
67
  }
68
+ function stated(text) {
69
+ for (const ch of text) if (ch !== "\0") return text;
70
+ }
64
71
  function bytesToInt(bytes) {
65
72
  let n = 0;
66
73
  for (const b of bytes) n = n * 256 + b;
@@ -328,7 +328,22 @@ export interface Type3Call {
328
328
  readonly ctm: Matrix;
329
329
  /** §8.5.3 — its place in the stream's painting order, as a form call has. */
330
330
  readonly order: number;
331
+ /**
332
+ * §9.6.5/§9.10 — the font SAYS what character this glyph stands for. A
333
+ * flowing reading re-sets that character in a face of its own, so drawing the
334
+ * procedure as well puts two copies of the letter on the page.
335
+ */
336
+ readonly readable: boolean;
331
337
  }
338
+ /**
339
+ * §9.10 — whether the font says what character a glyph stands for: text that is
340
+ * neither empty nor the replacement character a reader writes where nothing is
341
+ * stated.
342
+ *
343
+ * @param text What the font's decode gave for the code.
344
+ * @returns Whether a flowing reading can write it as a letter.
345
+ */
346
+ export declare function statesACharacter(text: string): boolean;
332
347
  /**
333
348
  * 2D affine matrix `[a b c d e f]`, row-vector convention (`[x y 1] · M`):
334
349
  * ```
@@ -35,6 +35,18 @@ function pathBox(segs) {
35
35
  }
36
36
  /** The area of a box, for choosing the smaller of two clip regions. */
37
37
  var area = (b) => Math.max(0, b.maxX - b.minX) * Math.max(0, b.maxY - b.minY);
38
+ /**
39
+ * §9.10 — whether the font says what character a glyph stands for: text that is
40
+ * neither empty nor the replacement character a reader writes where nothing is
41
+ * stated.
42
+ *
43
+ * @param text What the font's decode gave for the code.
44
+ * @returns Whether a flowing reading can write it as a letter.
45
+ */
46
+ function statesACharacter(text) {
47
+ const trimmed = text.trim();
48
+ return trimmed.length > 0 && !trimmed.includes("�");
49
+ }
38
50
  /** The identity {@link Matrix} (no transform). */
39
51
  var IDENTITY = [
40
52
  1,
@@ -284,7 +296,8 @@ function interpretContent(bytes, fonts, initialCtm = IDENTITY, shadings = /* @__
284
296
  stream,
285
297
  resources: type3.resources,
286
298
  ctm: multiply(type3.matrix, multiply(scale, multiply(tm, state.ctm))),
287
- order: paintOrder++
299
+ order: paintOrder++,
300
+ readable: statesACharacter(state.font.decode([code]))
288
301
  });
289
302
  }
290
303
  }
@@ -110,6 +110,14 @@ export declare class PdfFile {
110
110
  * reader takes one from whoever needs it:
111
111
  *
112
112
  * ```ts
113
+ * // browser: any wasm/JS decoder you ship — brotli-dec-wasm is ~200 KB
114
+ * import brotliPromise from 'brotli-dec-wasm';
115
+ * const brotli = await brotliPromise;
116
+ * Ream.parse(pdf, { filters: { BrotliDecode: (b) => brotli.decompress(b) } });
117
+ * ```
118
+ *
119
+ * ```ts
120
+ * // node, where the runtime already carries one
113
121
  * import { brotliDecompressSync } from 'node:zlib';
114
122
  * Ream.parse(pdf, { filters: { BrotliDecode: (b) => brotliDecompressSync(b) } });
115
123
  * ```
@@ -66,7 +66,7 @@ export interface TextSpan {
66
66
  * survives as its own run) and squashing whitespace. With no hrefs this
67
67
  * collapses to a single run — the same shape {@link paragraphBlock} produces.
68
68
  */
69
- export declare function paragraphFromRuns(spans: ReadonlyArray<TextSpan>, outlineLevel?: number, placement?: Pick<ParagraphProperties, 'alignment' | 'spacingBefore' | 'indentLeft' | 'indentFirstLine'>): BodyElement;
69
+ export declare function paragraphFromRuns(spans: ReadonlyArray<TextSpan>, outlineLevel?: number, placement?: Pick<ParagraphProperties, 'alignment' | 'spacingBefore' | 'indentLeft' | 'indentFirstLine' | 'tabs'>): BodyElement;
70
70
  /**
71
71
  * Store a {@link PdfImage}'s bytes (content-addressed dedup) and build the image
72
72
  * {@link BodyElement} that references them, sized in points from the placement
@@ -126,7 +126,12 @@ export declare function sectionFromPdfPages(pages: ReadonlyArray<PdfPage>): Sect
126
126
  export declare function withMeasuredMargins(section: SectionProperties | undefined, shown: ReadonlyArray<{
127
127
  width: number;
128
128
  height: number;
129
- }>, pageRuns: ReadonlyArray<ReadonlyArray<TextRun>>): SectionProperties | undefined;
129
+ }>, pageRuns: ReadonlyArray<ReadonlyArray<TextRun>>, pageMarks?: ReadonlyArray<ReadonlyArray<{
130
+ x: number;
131
+ y: number;
132
+ widthPt: number;
133
+ heightPt: number;
134
+ }>>): SectionProperties | undefined;
130
135
  /**
131
136
  * Assemble the final {@link FlowDoc} for a reconstruction: the body elements
132
137
  * with their styles resolved against the empty style sheet, the lifted-image
@@ -333,7 +333,7 @@ var DEEPEST_MARGIN = .5;
333
333
  var ASCENDER = .8;
334
334
  /** And its descender below — the two together are a little over one em. */
335
335
  var DESCENDER = .22;
336
- function withMeasuredMargins(section, shown, pageRuns) {
336
+ function withMeasuredMargins(section, shown, pageRuns, pageMarks = []) {
337
337
  if (!section?.pageSize) return section;
338
338
  const width = section.pageSize.width;
339
339
  const height = section.pageSize.height;
@@ -352,8 +352,10 @@ function withMeasuredMargins(section, shown, pageRuns) {
352
352
  let bottomSize = 0;
353
353
  for (const r of runs) {
354
354
  if (!Number.isFinite(r.x) || !Number.isFinite(r.y)) continue;
355
- minX = Math.min(minX, r.x);
356
- maxX = Math.max(maxX, r.endX);
355
+ if (r.text.trim() !== "") {
356
+ minX = Math.min(minX, r.x);
357
+ maxX = Math.max(maxX, r.endX);
358
+ }
357
359
  if (r.y < minY) {
358
360
  minY = r.y;
359
361
  bottomSize = r.fontSizePt;
@@ -363,6 +365,11 @@ function withMeasuredMargins(section, shown, pageRuns) {
363
365
  topSize = r.fontSizePt;
364
366
  }
365
367
  }
368
+ for (const mark of pageMarks[i] ?? []) {
369
+ if (!Number.isFinite(mark.x) || !Number.isFinite(mark.y)) continue;
370
+ maxX = Math.max(maxX, mark.x + mark.widthPt);
371
+ minY = Math.min(minY, mark.y);
372
+ }
366
373
  if (!Number.isFinite(minX) || !Number.isFinite(minY)) return;
367
374
  lefts.push(minX);
368
375
  rights.push(page.width - maxX);
@@ -378,7 +385,7 @@ function withMeasuredMargins(section, shown, pageRuns) {
378
385
  return {
379
386
  ...section,
380
387
  margins: {
381
- left: clamp(median(lefts), width),
388
+ left: clamp(Math.min(...lefts), width),
382
389
  right: clamp(median(rights) - width * SLACK, width),
383
390
  top: clamp(Math.min(...tops), height, DEEPEST_MARGIN),
384
391
  bottom: clamp(Math.min(...bottoms) - height * SLACK, height, DEEPEST_MARGIN)
@@ -36,6 +36,7 @@ function reconstructByLayout(file, mode = "flow") {
36
36
  const foot = mode === "positional" ? void 0 : runningFoot(allRuns, shown, "foot");
37
37
  const head = mode === "positional" ? void 0 : runningFoot(allRuns, shown, "head");
38
38
  const pageRuns = foot || head ? allRuns.map((runs, i) => runs.filter((r) => foot?.lift[i]?.has(r) !== true && head?.lift[i]?.has(r) !== true)) : allRuns;
39
+ const pageMarks = pages.map(() => []);
39
40
  const medianFont = median(pageRuns.flat().map((r) => r.fontSizePt).filter((s) => s > 0)) || 12;
40
41
  const resources = new ResourceStore();
41
42
  const losses = [];
@@ -71,12 +72,13 @@ function reconstructByLayout(file, mode = "flow") {
71
72
  const pageWidth = display.width;
72
73
  const stepped = stepsBetweenWords(runs);
73
74
  const blocks = [];
74
- const split = gutters.length > 0 ? assignColumns(runs, gutters) : void 0;
75
75
  const textEdges = pageTextEdges(runs);
76
+ const inColumns = proseColumns(runs, gutters, textEdges);
77
+ const split = gutters.length > 0 && inColumns ? assignColumns(runs, gutters) : void 0;
76
78
  const bandEpsilon = (median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10) / 2;
77
79
  const bandAt = (top) => bandOf(split?.breaks ?? [], top, bandEpsilon);
78
80
  const addColumn = (allRuns, col) => {
79
- const colRuns = mode === "positional" ? allRuns.filter((r) => r.type3 !== true && r.invisible !== true) : allRuns;
81
+ const colRuns = allRuns.filter((r) => r.type3 !== true && (mode !== "positional" || r.invisible !== true));
80
82
  if (mode === "positional") {
81
83
  for (const [angle, runs] of byAngle(colRuns)) for (const line of groupIntoLines(rotate(runs, -angle), true, stepped)) {
82
84
  if (line.text.length === 0) continue;
@@ -121,17 +123,38 @@ function reconstructByLayout(file, mode = "flow") {
121
123
  }
122
124
  });
123
125
  }
124
- for (const para of groupIntoParagraphs(lines, measure, display.height)) blocks.push({
125
- band: bandAt(para.top),
126
- col,
127
- top: para.top,
128
- el: paragraphFromRuns(para.spans, headingLevel(para.fontSize, medianFont), {
129
- ...para.alignment !== void 0 ? { alignment: para.alignment } : {},
130
- ...para.spacingBefore !== void 0 ? { spacingBefore: pt(para.spacingBefore) } : {},
131
- ...para.indentLeft !== void 0 ? { indentLeft: pt(para.indentLeft) } : {},
132
- ...para.indentFirstLine !== void 0 ? { indentFirstLine: pt(para.indentFirstLine) } : {}
133
- })
126
+ const paras = groupIntoParagraphs(lines, measure, display.height);
127
+ const asRows = tabbedRows(paras, measure && {
128
+ left: measure.left,
129
+ right: Math.max(measure.right, textEdges?.right ?? measure.right)
134
130
  });
131
+ for (const [at, para] of paras.entries()) {
132
+ const table = asRows.get(at);
133
+ if (table !== void 0) {
134
+ if (table !== null) blocks.push({
135
+ band: bandAt(para.top),
136
+ col,
137
+ top: para.top,
138
+ el: table
139
+ });
140
+ continue;
141
+ }
142
+ blocks.push({
143
+ band: bandAt(para.top),
144
+ col,
145
+ top: para.top,
146
+ el: paragraphFromRuns(para.spans, headingLevel(para.fontSize, medianFont), {
147
+ ...para.stops !== void 0 && para.stops.length > 0 ? { tabs: para.stops.filter((x) => x > 0).map((x) => ({
148
+ positionPt: pt(x),
149
+ alignment: "left"
150
+ })) } : {},
151
+ ...para.alignment !== void 0 ? { alignment: para.alignment } : {},
152
+ ...para.spacingBefore !== void 0 ? { spacingBefore: pt(para.spacingBefore) } : {},
153
+ ...para.indentLeft !== void 0 ? { indentLeft: pt(para.indentLeft) } : {},
154
+ ...para.indentFirstLine !== void 0 ? { indentFirstLine: pt(para.indentFirstLine) } : {}
155
+ })
156
+ });
157
+ }
135
158
  };
136
159
  const placed = [];
137
160
  const colOf = (centerX) => gutters.filter((g) => centerX >= g.mid).length;
@@ -145,6 +168,7 @@ function reconstructByLayout(file, mode = "flow") {
145
168
  losses: raw.losses
146
169
  };
147
170
  losses.push(...imgs.losses);
171
+ pageMarks[i] = imgs.images;
148
172
  const lifted = collectPageVectors(file, page, imgs.images.map((img) => ({
149
173
  minX: img.x,
150
174
  minY: img.y,
@@ -154,7 +178,8 @@ function reconstructByLayout(file, mode = "flow") {
154
178
  losses.push(...lifted.losses);
155
179
  const placedVectors = placeVectors(lifted.vectors, display);
156
180
  const ruled = markDrawnRules(runs, placedVectors);
157
- const vectors = placedVectors.filter((v) => !ruled.consumed.has(v));
181
+ const strayGlyphs = strayMarks(placedVectors, runs);
182
+ const vectors = placedVectors.filter((v) => !ruled.consumed.has(v)).filter((v) => !(mode !== "positional" && strayGlyphs.has(v)));
158
183
  const ruledIntoColumns = mode !== "positional" && looksRuled(ruled.runs, gutters, textEdges);
159
184
  const asTable = ruledIntoColumns && textEdges ? tableFrom(ruled.runs, gutters, textEdges, stepped) : void 0;
160
185
  if (asTable) for (const block of asTable) blocks.push({
@@ -169,6 +194,7 @@ function reconstructByLayout(file, mode = "flow") {
169
194
  for (const col of [SPANNING_COLUMN, ...columns]) addColumn(ruled.runs.filter((r) => columnFor(r) === col), col);
170
195
  } else addColumn(ruled.runs, 0);
171
196
  const under = mode !== "positional";
197
+ const givenAway = mode !== "positional" ? ruleBorders(vectors, blocks, display.width) : void 0;
172
198
  [
173
199
  ...imgs.images.map((img) => ({
174
200
  key: img.orderKey,
@@ -176,7 +202,7 @@ function reconstructByLayout(file, mode = "flow") {
176
202
  top: img.y + img.heightPt,
177
203
  make: (z) => imageBlock(img, resources, void 0, frame, z, under)
178
204
  })),
179
- ...vectors.map((v) => ({
205
+ ...vectors.filter((v) => givenAway?.has(v) !== true).map((v) => ({
180
206
  key: v.orderKey,
181
207
  col: colOf((v.minX + v.maxX) / 2),
182
208
  top: v.maxY,
@@ -218,7 +244,7 @@ function reconstructByLayout(file, mode = "flow") {
218
244
  runs: []
219
245
  }
220
246
  });
221
- const columnsHere = ruledIntoColumns ? 1 : gutters.length + 1;
247
+ const columnsHere = ruledIntoColumns || !proseColumns(runs, gutters, textEdges) ? 1 : gutters.length + 1;
222
248
  const spacePt = columnsHere > 1 ? median(gutters.map((g) => g.to - g.from)) : 0;
223
249
  for (const block of blocks) {
224
250
  const count = block.col === SPANNING_COLUMN ? 1 : columnsHere;
@@ -241,7 +267,7 @@ function reconstructByLayout(file, mode = "flow") {
241
267
  });
242
268
  const setUp = (from, to) => {
243
269
  const own = sectionFromPdfPages(pages.slice(from, to));
244
- return mode === "positional" ? own : withMeasuredMargins(own, shown.slice(from, to), pageRuns.slice(from, to));
270
+ return mode === "positional" ? own : withMeasuredMargins(own, shown.slice(from, to), pageRuns.slice(from, to), pageMarks.slice(from, to));
245
271
  };
246
272
  sectionEnds.push({
247
273
  at: body.length,
@@ -302,9 +328,9 @@ function detectGutters(runs, pageWidth) {
302
328
  if (span < pageWidth * .5) return [];
303
329
  const sorted = [...spans].sort((a, b) => a[0] - b[0]);
304
330
  let curEnd = sorted[0][1];
305
- const empty = [];
331
+ const bands = [];
306
332
  for (const [l, r] of sorted) {
307
- if (l - curEnd >= fontSize * 3) empty.push({
333
+ if (l - curEnd >= fontSize * 3) bands.push({
308
334
  mid: (curEnd + l) / 2,
309
335
  from: curEnd,
310
336
  to: l
@@ -315,6 +341,17 @@ function detectGutters(runs, pageWidth) {
315
341
  const ink = runInk(r);
316
342
  return ink ? [ink] : [];
317
343
  }).sort((a, b) => a[0] - b[0]));
344
+ const straddling = (band) => {
345
+ let n = 0;
346
+ for (const row of rows) {
347
+ const before = row.filter(([, r]) => r <= band.from);
348
+ const after = row.filter(([l]) => l >= band.to);
349
+ if (before.length === 0 || after.length === 0) continue;
350
+ if (Math.min(...after.map(([l]) => l)) - Math.max(...before.map(([, r]) => r)) >= fontSize * MIN_GUTTER_EM) n++;
351
+ }
352
+ return n;
353
+ };
354
+ const empty = bands.filter((b) => straddling(b) >= MIN_EMPTY_BAND_ROWS);
318
355
  const voted = [];
319
356
  for (const x of sample(minX + span * .1, minX + span * .9, 1)) {
320
357
  let columned = 0;
@@ -442,6 +479,13 @@ var MIN_SHARED_ROWS = 3;
442
479
  /** How many lines have to be split at the same place before the page is in columns. */
443
480
  var MIN_COLUMNED_ROWS = 12;
444
481
  /**
482
+ * How many lines a band NO run crosses must separate before it is a gutter
483
+ * rather than an empty half of a page. Lower than the vote's bar, because a
484
+ * band nothing crosses is stronger evidence than a band most lines avoid — but
485
+ * not zero, which is what an invoice's blank left half offers.
486
+ */
487
+ var MIN_EMPTY_BAND_ROWS = 8;
488
+ /**
445
489
  * The page's runs grouped by baseline.
446
490
  *
447
491
  * Swept in order rather than bucketed: a superscript sits a few points above
@@ -781,7 +825,7 @@ function inkSpan(runs) {
781
825
  }
782
826
  /** One run of runs, left to right on a shared baseline, as a {@link Line}. */
783
827
  function lineOf(runs, y, fontSize, stepped) {
784
- const ordered = lineSpans(runs, fontSize, stepped || runs.length > 1 && !runs.some((r) => SPACE.test(r.text)));
828
+ const { spans: ordered, stops } = lineSpans(runs, fontSize, stepped || runs.length > 1 && !runs.some((r) => SPACE.test(r.text)));
785
829
  const spans = ordered.every((s) => s.text.trim() === "" || isRightToLeft(s.text)) ? [...ordered].reverse() : ordered;
786
830
  const ink = inkSpan(runs);
787
831
  return {
@@ -790,6 +834,7 @@ function lineOf(runs, y, fontSize, stepped) {
790
834
  y,
791
835
  fontSize,
792
836
  ...tabbed(runs, fontSize) ? { tabbed: true } : {},
837
+ ...stops.length > 0 ? { stops } : {},
793
838
  text: spans.map((s) => s.text).join("").replace(/\s+/g, " ").trim(),
794
839
  spans
795
840
  };
@@ -887,11 +932,15 @@ function stepsBetweenWords(runs) {
887
932
  }
888
933
  function lineSpans(runs, fontSize, stepped) {
889
934
  const spans = [];
935
+ const stops = [];
890
936
  const body = runs.filter((r) => (r.fontSizePt || fontSize) > fontSize * SCRIPT_SIZE);
891
937
  const baseline = median((body.length > 0 ? body : runs).map((r) => r.y));
892
938
  let prev;
893
939
  for (const run of runs) {
894
- if (prev !== void 0 && run.x - prev.endX > spaceGap(prev, fontSize, stepped) && !(isLeader(prev.text) && prev.text === run.text)) spans.push({ text: " " });
940
+ if (prev !== void 0 && run.x - prev.endX > spaceGap(prev, fontSize, stepped) && !(isLeader(prev.text) && prev.text === run.text)) if (run.x - prev.endX >= (run.fontSizePt || fontSize) * TAB_GAP_EM) {
941
+ spans.push({ text: " " });
942
+ stops.push(run.x);
943
+ } else spans.push({ text: " " });
895
944
  spans.push({
896
945
  text: run.text.replaceAll("�", ""),
897
946
  sizePt: run.fontSizePt,
@@ -909,7 +958,10 @@ function lineSpans(runs, fontSize, stepped) {
909
958
  });
910
959
  prev = run;
911
960
  }
912
- return spans;
961
+ return {
962
+ spans,
963
+ stops
964
+ };
913
965
  }
914
966
  /**
915
967
  * §17.3.2.42 — a run set off the line's baseline, and smaller, as the script it
@@ -960,6 +1012,24 @@ function carriesLeader(line) {
960
1012
  }
961
1013
  /** Three of the same leader character in a row is a leader and not punctuation. */
962
1014
  var LEADER_RUN = /([.\u00b7_])\1{2,}/u;
1015
+ /**
1016
+ * Whether the line is a RULE drawn out of characters — a row of hyphens, dots
1017
+ * or underscores and nothing else.
1018
+ *
1019
+ * A page with no rule to draw types one. It is not a sentence, it does not run
1020
+ * on into the line under it and the line over it does not run on into it.
1021
+ *
1022
+ * @param line The line.
1023
+ * @returns Whether it is a rule rather than words.
1024
+ */
1025
+ function ruleOfCharacters(line) {
1026
+ const text = line.text.trim();
1027
+ if (text.length < RULE_CHARS) return false;
1028
+ for (const ch of text) if (!LEADER_CHARS.has(ch)) return false;
1029
+ return true;
1030
+ }
1031
+ /** How many of them it takes before a row of marks is a rule. */
1032
+ var RULE_CHARS = 3;
963
1033
  function groupIntoParagraphs(lines, column, pageHeight = 0) {
964
1034
  const groups = [];
965
1035
  const gaps = [];
@@ -967,7 +1037,7 @@ function groupIntoParagraphs(lines, column, pageHeight = 0) {
967
1037
  for (const line of lines) {
968
1038
  const gap = prev !== void 0 ? prev.y - line.y : 0;
969
1039
  const opened = prev !== void 0 && gap > line.fontSize * 1.5;
970
- if (groups.length === 0 || opened || prev !== void 0 && (endedParagraph(prev, line, column) || carriesLeader(prev) || prev.tabbed === true)) {
1040
+ if (groups.length === 0 || opened || ruleOfCharacters(line) || prev !== void 0 && (ruleOfCharacters(prev) || endedParagraph(prev, line, column) || carriesLeader(prev) || prev.tabbed === true)) {
971
1041
  groups.push([]);
972
1042
  gaps.push(prev === void 0 ? 0 : gap);
973
1043
  }
@@ -982,10 +1052,12 @@ function groupIntoParagraphs(lines, column, pageHeight = 0) {
982
1052
  const opened = (gaps[i] ?? 0) - fontSize * 1.2;
983
1053
  const most = pageHeight > 0 ? pageHeight / 3 : fontSize * 3;
984
1054
  const spacingBefore = opened > fontSize * .3 ? Math.min(opened, most) : void 0;
1055
+ const stops = g.length === 1 ? first.stops ?? [] : [];
985
1056
  return {
986
1057
  spans: joinLines(g),
987
1058
  fontSize,
988
1059
  top: first.y,
1060
+ ...stops.length > 0 ? { stops: stops.map((x) => x - (column?.left ?? columnLeft)) } : {},
989
1061
  ...spacingBefore !== void 0 ? { spacingBefore } : {},
990
1062
  ...alignmentOf(g, column),
991
1063
  ...indentOf(g, columnLeft, alignmentOf(g, column).alignment)
@@ -1204,11 +1276,21 @@ function edgeLine(runs, pageHeight, where) {
1204
1276
  if (runs.length < 4 || pageHeight <= 0) return void 0;
1205
1277
  const fontSize = median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10;
1206
1278
  const rows = rowsOf(runs, fontSize);
1207
- if (rows.length < 4) return void 0;
1279
+ if (rows.length === 0) return void 0;
1208
1280
  const at = (i) => where === "foot" ? rows[rows.length - 1 - i] : rows[i];
1209
1281
  const edgeY = (row) => where === "foot" ? Math.max(...row.map((r) => r.y)) : Math.min(...row.map((r) => r.y));
1210
1282
  const inside = (row) => where === "foot" ? Math.min(...row.map((r) => r.y)) : Math.max(...row.map((r) => r.y));
1211
1283
  const y = edgeY(at(0));
1284
+ if (rows.length < 4) {
1285
+ const inked = rows.filter((row) => row.some((r) => r.text.trim() !== ""));
1286
+ const inBand = inked.length > 0 && inked.every((row) => where === "foot" ? edgeY(row) <= pageHeight * FOOT_BAND : edgeY(row) >= pageHeight * (1 - FOOT_BAND));
1287
+ const all = inked.flat();
1288
+ const text = all.map((r) => r.text).join("").trim();
1289
+ return inBand && text.length > 0 && text.length <= FOOT_CHARS * inked.length ? {
1290
+ y: edgeY(inked[inked.length - 1] ?? []),
1291
+ runs: all
1292
+ } : void 0;
1293
+ }
1212
1294
  let found;
1213
1295
  let group = [];
1214
1296
  const most = where === "foot" ? FOOT_ROWS : 1;
@@ -1252,6 +1334,25 @@ function pageNumbered(run) {
1252
1334
  const number = found[2];
1253
1335
  const before = run.text.slice(0, at);
1254
1336
  const after = run.text.slice(at + number.length);
1337
+ const total = /(^|\s)(\d{1,4})(\s|$)/u.exec(after);
1338
+ const tail = total === null ? after === "" ? [] : [{
1339
+ ...run,
1340
+ text: after
1341
+ }] : [
1342
+ ...after.slice(0, total.index + total[1].length) === "" ? [] : [{
1343
+ ...run,
1344
+ text: after.slice(0, total.index + total[1].length)
1345
+ }],
1346
+ {
1347
+ ...run,
1348
+ text: total[2],
1349
+ field: "NUMPAGES"
1350
+ },
1351
+ ...after.slice(total.index + total[1].length + total[2].length) === "" ? [] : [{
1352
+ ...run,
1353
+ text: after.slice(total.index + total[1].length + total[2].length)
1354
+ }]
1355
+ ];
1255
1356
  return [
1256
1357
  ...before === "" ? [] : [{
1257
1358
  ...run,
@@ -1262,10 +1363,7 @@ function pageNumbered(run) {
1262
1363
  text: number,
1263
1364
  field: "PAGE"
1264
1365
  },
1265
- ...after === "" ? [] : [{
1266
- ...run,
1267
- text: after
1268
- }]
1366
+ ...tail
1269
1367
  ];
1270
1368
  }
1271
1369
  /**
@@ -1404,6 +1502,281 @@ function looksRuled(runs, gutters, edges) {
1404
1502
  return (fills[Math.floor(fills.length / 2)] ?? 1) <= TABLE_CELL_FILL;
1405
1503
  }
1406
1504
  /**
1505
+ * Whether the regions the gutters cut the page into are COLUMNS OF PROSE — the
1506
+ * only thing worth re-setting a document in columns for.
1507
+ *
1508
+ * A gutter is evidence about the page's white space, and white space is not
1509
+ * enough: an invoice sets its labels along the left and its amounts against the
1510
+ * right margin, and a dozen lines then break at the same x. Read as two columns
1511
+ * the amounts became a column of their own and every label was indented past
1512
+ * the strip it was given — "Total excluding tax" came back one letter per line.
1513
+ *
1514
+ * What tells them apart is the same thing that tells a cell from a line of
1515
+ * prose: a column of prose FILLS its measure, over and over, because that is
1516
+ * what wrapping does. A column of values never fills anything — its lines are
1517
+ * short and it is their right edges that line up, not their left. So every
1518
+ * region must hold lines of its own and they must reach across it.
1519
+ *
1520
+ * @param runs The page's runs.
1521
+ * @param gutters The page's gutters.
1522
+ * @param edges Where the page's text starts and ends.
1523
+ * @returns Whether the page is set in columns of prose.
1524
+ */
1525
+ function proseColumns(runs, gutters, edges) {
1526
+ if (gutters.length === 0) return false;
1527
+ if (!edges) return true;
1528
+ const rows = rowsOf(runs, median(runs.map((r) => r.fontSizePt).filter((s) => s > 0)) || 10).filter((row) => row.length > 0);
1529
+ if (rows.length === 0) return true;
1530
+ const bounds = columnBounds(runs, gutters, edges);
1531
+ const regions = bounds.slice(0, -1).map((lo, i) => [lo, bounds[i + 1]]);
1532
+ const regionOf = (x) => {
1533
+ for (let i = regions.length - 1; i >= 0; i--) if (x >= regions[i][0]) return i;
1534
+ return 0;
1535
+ };
1536
+ const edgesIn = regions.map(() => []);
1537
+ for (const row of rows) {
1538
+ const byRegion = regions.map(() => []);
1539
+ for (const run of row) byRegion[regionOf(run.x)].push(run);
1540
+ byRegion.forEach((inRegion, i) => {
1541
+ if (inRegion.length === 0) return;
1542
+ edgesIn[i].push({
1543
+ left: Math.min(...inRegion.map((r) => r.x)),
1544
+ right: Math.max(...inRegion.map((r) => r.endX))
1545
+ });
1546
+ });
1547
+ }
1548
+ const agreeing = (xs) => {
1549
+ let most = 0;
1550
+ for (const x of xs) most = Math.max(most, xs.filter((y) => Math.abs(y - x) <= FLUSH_PT).length);
1551
+ return most;
1552
+ };
1553
+ return edgesIn.every((lines) => {
1554
+ if (lines.length < MIN_PROSE_LINES) return false;
1555
+ return agreeing(lines.map((l) => l.right)) <= agreeing(lines.map((l) => l.left));
1556
+ });
1557
+ }
1558
+ /**
1559
+ * The drawn glyphs that are STRAY marks in a line of ordinary text.
1560
+ *
1561
+ * §9.6.6 — a glyph the file states no character for is drawn, because nothing
1562
+ * downstream can write it. That is the whole content of some pages: a subset
1563
+ * that names its glyphs `g18`, a program with no `cmap`, a font of pictures.
1564
+ * On such a page the drawings ARE the words and they stay.
1565
+ *
1566
+ * A single mark among readable words is the other case, and Stripe's invoices
1567
+ * are full of them — a `/ToUnicode` stating U+0000 for every piece of
1568
+ * punctuation it sets. The flowing reading has nowhere to put a shape inside a
1569
+ * line, so the colon of "Kazakhstan VAT: 86-1696045" was placed as a floating
1570
+ * drawing and landed a word away, on a line of its own, pushing the text
1571
+ * around it aside. Dropped, the line keeps its words and the loss report still
1572
+ * says a character was unrecoverable.
1573
+ *
1574
+ * The line decides: where nearly everything on it is drawn, the drawings are
1575
+ * the text — bug1151216.pdf sets three lines of prices that way. Where one or
1576
+ * two marks stand among several readable runs, they are punctuation.
1577
+ *
1578
+ * @param vectors The page's painted paths.
1579
+ * @param runs The page's runs.
1580
+ * @returns The drawn glyphs to leave out of a flowing reading.
1581
+ */
1582
+ function strayMarks(vectors, runs) {
1583
+ const glyphs = vectors.filter((v) => v.glyph === true);
1584
+ const out = /* @__PURE__ */ new Set();
1585
+ if (glyphs.length === 0) return out;
1586
+ const readable = runs.filter((r) => r.text.trim() !== "" && !r.text.includes("�"));
1587
+ for (const v of glyphs) {
1588
+ const mid = (v.minY + v.maxY) / 2;
1589
+ const size = v.maxY - v.minY || 10;
1590
+ const words = readable.filter((r) => Math.abs(r.y - mid) <= size);
1591
+ const before = words.some((r) => r.endX <= v.minX + size);
1592
+ const after = words.some((r) => r.x >= v.maxX - size);
1593
+ if (!before && !after) continue;
1594
+ if (glyphs.filter((g) => Math.abs((g.minY + g.maxY) / 2 - mid) <= size).length <= MOST_STRAY_MARKS && words.length >= LEAST_READABLE_RUNS) out.add(v);
1595
+ }
1596
+ return out;
1597
+ }
1598
+ /** How few drawn glyphs a line may hold before they are its text, not marks in it. */
1599
+ var MOST_STRAY_MARKS = 2;
1600
+ /** …and how many readable runs must stand on that line for them to be strays. */
1601
+ var LEAST_READABLE_RUNS = 4;
1602
+ /**
1603
+ * §17.4.38 — the consecutive lines a page set out on the SAME stops, as the
1604
+ * TABLE they are.
1605
+ *
1606
+ * A line broken at a gap no word space explains is a line the page laid out
1607
+ * (see {@link tabbed}); several of them one under another, broken in the same
1608
+ * places, is a table's head and its rows. An invoice's item table is exactly
1609
+ * that, and so is the payment history under it.
1610
+ *
1611
+ * Two stops at least, which is three columns: ONE stop is a contents entry with
1612
+ * its page number at the measure, and a list of those is a list.
1613
+ *
1614
+ * The stops do not have to agree to the point — a heading sits over its column
1615
+ * and a figure is set against the far side of it — so they are matched within
1616
+ * an em, and the column is put where the rows agree it is.
1617
+ *
1618
+ * @param paras The column's paragraphs, in order.
1619
+ * @param measure The measure they are set across.
1620
+ * @returns For each paragraph that belongs to a table: the table at its first
1621
+ * row, and `null` at the rows after it (their content is inside it).
1622
+ */
1623
+ function tabbedRows(paras, measure) {
1624
+ const out = /* @__PURE__ */ new Map();
1625
+ if (!measure) return out;
1626
+ for (let i = 0; i < paras.length;) {
1627
+ const stops = paras[i]?.stops ?? [];
1628
+ if (stops.length < LEAST_TABLE_STOPS) {
1629
+ i++;
1630
+ continue;
1631
+ }
1632
+ const near = (a, b) => a.length === b.length && a.every((x, k) => Math.abs(x - b[k]) <= Math.max(paras[i].fontSize, TABLE_STOP_SLACK_PT));
1633
+ let to = i + 1;
1634
+ while (to < paras.length && near(stops, paras[to]?.stops ?? [])) to++;
1635
+ if (to - i < 2) {
1636
+ i++;
1637
+ continue;
1638
+ }
1639
+ const rows = paras.slice(i, to);
1640
+ const bounds = stops.map((_, k) => Math.min(...rows.map((r) => r.stops[k])));
1641
+ const width = measure.right - measure.left;
1642
+ const edges = [
1643
+ 0,
1644
+ ...bounds,
1645
+ width
1646
+ ];
1647
+ out.set(i, {
1648
+ kind: "table",
1649
+ table: {
1650
+ properties: {
1651
+ defaultCellMargins: {
1652
+ left: pt(0),
1653
+ right: pt(0)
1654
+ },
1655
+ layout: "fixed"
1656
+ },
1657
+ grid: edges.slice(0, -1).map((from, k) => pt(Math.max(edges[k + 1] - from, 1))),
1658
+ rows: rows.map((row, r) => {
1659
+ const prev = rows[r - 1];
1660
+ const pitch = prev ? prev.top - row.top - row.fontSize * 1.2 : 0;
1661
+ const opening = r === 0 ? row.spacingBefore : pitch > row.fontSize * .3 ? pitch : void 0;
1662
+ return {
1663
+ properties: {},
1664
+ cells: splitAtTabs(row.spans).map((cell) => ({
1665
+ properties: {},
1666
+ content: [paragraphFromRuns(cell, void 0, opening !== void 0 ? { spacingBefore: pt(opening) } : {})]
1667
+ }))
1668
+ };
1669
+ })
1670
+ }
1671
+ });
1672
+ for (let k = i + 1; k < to; k++) out.set(k, null);
1673
+ i = to;
1674
+ }
1675
+ return out;
1676
+ }
1677
+ /** How many stops a line must stand on before a run of them is a table. */
1678
+ var LEAST_TABLE_STOPS = 2;
1679
+ /**
1680
+ * How far two rows' stops may stand apart and still be one column.
1681
+ *
1682
+ * A heading is set over its column and a figure against the far side of it: an
1683
+ * invoice's "Qty" stands eight points left of the "1" under it, and the heading
1684
+ * is set two sizes smaller, so its own em is not a wide enough tolerance.
1685
+ */
1686
+ var TABLE_STOP_SLACK_PT = 10;
1687
+ /** One line's spans cut into its cells, at the tabs the page set out. */
1688
+ function splitAtTabs(spans) {
1689
+ const cells = [[]];
1690
+ for (const span of spans) if (span.text === " ") cells.push([]);
1691
+ else cells[cells.length - 1].push(span);
1692
+ return cells;
1693
+ }
1694
+ /**
1695
+ * Give each separator rule to the paragraph it separates, as that paragraph's
1696
+ * own border (§17.3.1.24), and say which rules were given away.
1697
+ *
1698
+ * A rule sits in the white between two blocks: under a table's headings, over a
1699
+ * total. The block BELOW it takes it as a top border, because that is the block
1700
+ * the rule introduces; where nothing follows closely enough, the block above
1701
+ * takes it as a bottom one.
1702
+ *
1703
+ * @param vectors The page's painted paths.
1704
+ * @param blocks The blocks read off the page so far, which the rule joins.
1705
+ * @param width The page's width, which a rule is long relative to.
1706
+ * @returns The rules that became borders, and so must not be drawn again.
1707
+ */
1708
+ function ruleBorders(vectors, blocks, width) {
1709
+ const given = /* @__PURE__ */ new Set();
1710
+ const paragraphs = blocks.filter((b) => b.el.kind === "paragraph");
1711
+ if (paragraphs.length === 0) return given;
1712
+ for (const v of vectors) {
1713
+ if (!separatorRule(v, width)) continue;
1714
+ const y = (v.minY + v.maxY) / 2;
1715
+ const below = paragraphs.filter((b) => b.top < y).sort((a, b) => b.top - a.top)[0];
1716
+ const above = paragraphs.filter((b) => b.top >= y).sort((a, b) => a.top - b.top)[0];
1717
+ const side = below !== void 0 && y - below.top <= RULE_REACH_PT ? {
1718
+ block: below,
1719
+ edge: "top"
1720
+ } : above !== void 0 && above.top - y <= RULE_REACH_PT ? {
1721
+ block: above,
1722
+ edge: "bottom"
1723
+ } : void 0;
1724
+ if (!side || side.block.el.kind !== "paragraph") continue;
1725
+ const border = {
1726
+ style: "single",
1727
+ width: pt(Math.max(v.lineWidth ?? v.maxY - v.minY, RULE_MIN_PT)),
1728
+ colorHex: v.strokeHex ?? v.fillHex ?? "000000"
1729
+ };
1730
+ const { paragraph } = side.block.el;
1731
+ side.block.el = {
1732
+ kind: "paragraph",
1733
+ paragraph: {
1734
+ ...paragraph,
1735
+ properties: {
1736
+ ...paragraph.properties,
1737
+ borders: {
1738
+ ...paragraph.properties.borders,
1739
+ [side.edge]: border
1740
+ }
1741
+ }
1742
+ }
1743
+ };
1744
+ given.add(v);
1745
+ }
1746
+ return given;
1747
+ }
1748
+ /** How far from a paragraph a rule may stand and still belong to it. */
1749
+ var RULE_REACH_PT = 14;
1750
+ /** The thinnest a border may be drawn and still be seen. */
1751
+ var RULE_MIN_PT = .5;
1752
+ /**
1753
+ * Whether a painted path is a RULE — a line drawn to separate one block from
1754
+ * the next, rather than a piece of the page's artwork.
1755
+ *
1756
+ * Flat and long: a hairline the width of a column under a table's headings, or
1757
+ * the line a total is written over. Everything else — boxes, panels, drawings —
1758
+ * keeps the place on the page it was drawn at.
1759
+ *
1760
+ * @param v The painted path.
1761
+ * @param width The page's width, which the rule is long RELATIVE to.
1762
+ * @returns Whether it separates blocks rather than drawing something.
1763
+ */
1764
+ function separatorRule(v, width) {
1765
+ const w = v.maxX - v.minX;
1766
+ const h = v.maxY - v.minY;
1767
+ return h <= RULE_THICK_PT && w >= width * RULE_SHARE && w > h * RULE_RATIO;
1768
+ }
1769
+ /** How thick a mark may be and still be a rule. */
1770
+ var RULE_THICK_PT = 2;
1771
+ /** How much of the sheet it must run across. */
1772
+ var RULE_SHARE = .2;
1773
+ /** …and how much longer than it is thick. */
1774
+ var RULE_RATIO = 20;
1775
+ /** How close two edges stand before they count as the same one. */
1776
+ var FLUSH_PT = 1;
1777
+ /** And how many lines it takes before a region is a column at all. */
1778
+ var MIN_PROSE_LINES = 6;
1779
+ /**
1407
1780
  * §17.4.38 — the page's rows as the TABLE they are.
1408
1781
  *
1409
1782
  * The regions between the gutters are the columns and the rows are the rows;
@@ -323,7 +323,7 @@ function reconstructTaggedPdf(file) {
323
323
  const reached = [...claimed].reduce((n, r) => n + r.text.length, 0);
324
324
  if (onPage > 0 && reached * 2 < onPage) return void 0;
325
325
  return {
326
- doc: buildFlowDoc(body, resources, withMeasuredMargins(sectionFromPdfPages(pages), shown, placedRuns), collectEmbeddedFonts(file, pages, imageLosses)),
326
+ doc: buildFlowDoc(body, resources, withMeasuredMargins(sectionFromPdfPages(pages), shown, placedRuns, pageImages.map((p) => p.images)), collectEmbeddedFonts(file, pages, imageLosses)),
327
327
  losses: imageLosses
328
328
  };
329
329
  }
@@ -37,6 +37,8 @@ export interface PdfVector {
37
37
  readonly minY: number;
38
38
  readonly maxX: number;
39
39
  readonly maxY: number;
40
+ /** §9.6.6 — a traced glyph rather than artwork: type the file states no character for. */
41
+ readonly glyph?: boolean;
40
42
  readonly mcid?: number;
41
43
  }
42
44
  /**
@@ -199,6 +199,7 @@ function collectPageVectors(file, page, occupied = []) {
199
199
  ...v.lineWidth !== void 0 ? { lineWidth: v.lineWidth } : {}
200
200
  } : {},
201
201
  ...b,
202
+ ...v.glyph === true ? { glyph: true } : {},
202
203
  ...v.mcid !== void 0 ? { mcid: v.mcid } : {}
203
204
  });
204
205
  }
@@ -482,7 +482,7 @@ function chartBlockXml(chart, state, scope, losses) {
482
482
  const cx = Math.round(chart.width * EMU_PER_PT);
483
483
  const cy = Math.round(chart.height * EMU_PER_PT);
484
484
  const id = ++state.drawingSeq;
485
- return `<w:drawing><wp:inline xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"><wp:extent cx="${cx}" cy="${cy}"/><wp:docPr id="${id}" name="Chart ${id}"${chart.altText ? ` descr="${escapeAttr(chart.altText)}"` : ""}/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="${relId}"/></a:graphicData></a:graphic></wp:inline></w:drawing>`;
485
+ return `<w:drawing><wp:inline xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" distT="0" distB="0" distL="0" distR="0"><wp:extent cx="${cx}" cy="${cy}"/><wp:docPr id="${id}" name="Chart ${id}"${chart.altText ? ` descr="${escapeAttr(chart.altText)}"` : ""}/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="${relId}"/></a:graphicData></a:graphic></wp:inline></w:drawing>`;
486
486
  }
487
487
  /**
488
488
  * §20.4.2.3 — the frame a drawing is placed by: `wp:anchor` where the block
@@ -510,7 +510,7 @@ function chartBlockXml(chart, state, scope, losses) {
510
510
  */
511
511
  function drawingFrame(float, cx, cy, head, graphic, state, rotation60k) {
512
512
  const WP_NS = " xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\"";
513
- if (!float) return `<w:drawing><wp:inline${WP_NS}><wp:extent cx="${cx}" cy="${cy}"/>${head}${graphic}</wp:inline></w:drawing>`;
513
+ if (!float) return `<w:drawing><wp:inline${WP_NS} distT="0" distB="0" distL="0" distR="0"><wp:extent cx="${cx}" cy="${cy}"/>${head}${graphic}</wp:inline></w:drawing>`;
514
514
  const emu = (pt) => Number.isFinite(pt) ? Math.round((pt ?? 0) * EMU_PER_PT) : 0;
515
515
  const d = float.wrapDist;
516
516
  const dist = ` distT="${String(emu(d?.topPt))}" distB="${String(emu(d?.bottomPt))}" distL="${String(emu(d?.leftPt))}" distR="${String(emu(d?.rightPt))}"`;
@@ -821,22 +821,27 @@ function runXml(run, state, scope) {
821
821
  if (run.noteNumber) return `<w:r>${rPr}<${scope.noteKind === "endnote" ? "w:endnoteRef" : "w:footnoteRef"}/></w:r>`;
822
822
  if (run.commentRef !== void 0) return `<w:r>${rPr}<w:commentReference w:id="${escapeAttr(run.commentRef)}"/></w:r>`;
823
823
  const brk = run.pageBreak ? "<w:br w:type=\"page\"/>" : "";
824
+ if (run.field !== void 0 && run.text !== "") {
825
+ const inner = `<w:r>${rPr}<w:t xml:space="preserve">${escapeXml(run.text)}</w:t></w:r>`;
826
+ return `<w:fldSimple w:instr=" ${run.field} ">${inner}</w:fldSimple>${brk}`;
827
+ }
824
828
  if (run.text === "") return brk ? `<w:r>${rPr}${brk}</w:r>` : "";
825
- return `<w:r>${rPr}<w:t xml:space="preserve">${escapeXml(run.text)}</w:t>${brk}</w:r>`;
829
+ return `<w:r>${rPr}${run.text.split(" ").map((piece) => piece === "" ? "" : `<w:t xml:space="preserve">${escapeXml(piece)}</w:t>`).join("<w:tab/>")}${brk}</w:r>`;
826
830
  }
827
831
  function rPrXml(r) {
828
832
  const out = [];
833
+ const states = (key) => r[key] !== void 0 && r[key] !== DEFAULT_RUN[key];
829
834
  const fonts = rFontsXml(r.fontFamily);
830
835
  if (fonts) out.push(fonts);
831
- if (r.bold !== DEFAULT_RUN.bold) out.push(toggle("w:b", r.bold));
832
- if (r.italic !== DEFAULT_RUN.italic) out.push(toggle("w:i", r.italic));
833
- if (r.strike !== DEFAULT_RUN.strike) out.push(toggle("w:strike", r.strike));
834
- if (r.colorHex !== DEFAULT_RUN.colorHex) out.push(`<w:color w:val="${r.colorHex}"/>`);
835
- if (r.fontSizePt !== DEFAULT_RUN.fontSizePt) out.push(`<w:sz w:val="${Math.round(r.fontSizePt * 2)}"/>`);
836
- if (r.underline !== DEFAULT_RUN.underline) out.push(underlineXml(r));
836
+ if (states("bold")) out.push(toggle("w:b", r.bold));
837
+ if (states("italic")) out.push(toggle("w:i", r.italic));
838
+ if (states("strike")) out.push(toggle("w:strike", r.strike));
839
+ if (states("colorHex")) out.push(`<w:color w:val="${r.colorHex}"/>`);
840
+ if (states("fontSizePt")) out.push(`<w:sz w:val="${Math.round(r.fontSizePt * 2)}"/>`);
841
+ if (states("underline")) out.push(underlineXml(r));
837
842
  if (r.shadingColorHex !== void 0) out.push(runShdXml(r.shadingColorHex));
838
- if (r.verticalAlign !== DEFAULT_RUN.verticalAlign) out.push(`<w:vertAlign w:val="${r.verticalAlign}"/>`);
839
- if (r.rtl !== DEFAULT_RUN.rtl) out.push(toggle("w:rtl", r.rtl));
843
+ if (states("verticalAlign")) out.push(`<w:vertAlign w:val="${r.verticalAlign}"/>`);
844
+ if (states("rtl")) out.push(toggle("w:rtl", r.rtl));
840
845
  if (r.lang !== void 0) out.push(`<w:lang w:val="${escapeAttr(r.lang)}"/>`);
841
846
  return out.length > 0 ? `<w:rPr>${out.join("")}</w:rPr>` : "";
842
847
  }
@@ -858,6 +863,17 @@ function pPrBody(p) {
858
863
  const spacing = spacingXml(p);
859
864
  if (spacing) out.push(spacing);
860
865
  if (JC.has(p.alignment) && p.alignment !== DEFAULT_PARA.alignment) out.push(`<w:jc w:val="${p.alignment}"/>`);
866
+ const pBdr = bordersXml("w:pBdr", p.borders);
867
+ if (pBdr) out.push(pBdr);
868
+ const tabs = p.tabs ?? [];
869
+ if (tabs.length > 0) {
870
+ const stops = tabs.map((t) => {
871
+ const pos = twips(t.positionPt);
872
+ const leader = t.leader !== void 0 ? ` w:leader="${escapeAttr(t.leader)}"` : "";
873
+ return `<w:tab w:val="${escapeAttr(t.alignment ?? "left")}" w:pos="${String(pos)}"${leader}/>`;
874
+ }).join("");
875
+ out.push(`<w:tabs>${stops}</w:tabs>`);
876
+ }
861
877
  return out.join("");
862
878
  }
863
879
  function pPrWithSect(p, closingSectPr) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reamkit",
3
- "version": "1.28.0",
3
+ "version": "1.29.0",
4
4
  "description": "Ream — convert DOCX, XLSX, PPTX and PDF to PDF, SVG, HTML, DOCX and XLSX, built from scratch on the ECMA-376 and ISO 32000 specifications. Parse once, convert anywhere.",
5
5
  "license": "MIT",
6
6
  "author": "Alex Krassavin <info@reamkit.dev>",