@uurtech/jdf-cli 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -656,6 +656,14 @@ function textExtent(r, k, stretched) {
656
656
  if (stretched) return Math.max(em * 0.5, Math.min(r.width, est));
657
657
  return Math.max(em * 0.5, r.width > est * 1.4 ? est : r.width);
658
658
  }
659
+ var numeric = (s) => {
660
+ const t = s.trim();
661
+ if (!t) return false;
662
+ if (/^[—–\-]+$/.test(t)) return true;
663
+ const core = t.replace(/[\s$€£¥(),%]/g, "").replace(/^[+\-−–]/, "");
664
+ return /^\d+(\.\d+)?(ms|s|k|m|b|M|K|B|x|×)?$/.test(core);
665
+ };
666
+ var isCurrencySymbol = (s) => /^[$€£¥]$/.test(s.trim());
659
667
  function groupRows(runs, skip) {
660
668
  const k = calibrateGlyphWidth(runs);
661
669
  const stretched = hasStretchedSpaces(runs, k);
@@ -677,179 +685,658 @@ function groupRows(runs, skip) {
677
685
  for (const row of rows) {
678
686
  row.cells.sort((a, b) => a.x0 - b.x0);
679
687
  const merged = [];
680
- for (const c of row.cells) {
688
+ let pendingSym = null;
689
+ const pushCell = (c) => {
681
690
  const last = merged[merged.length - 1];
682
691
  const em = c.run.fontSize * PT_TO_MM;
683
- if (last && c.x0 - last.x1 <= em * 1) {
692
+ const limit = last && numeric(last.run.text) && numeric(c.run.text) ? em * 0.35 : em * 1;
693
+ if (last && c.x0 - last.x1 <= limit) {
694
+ last.parts = [
695
+ ...last.parts ?? [{ text: last.run.text, x0: last.x0, x1: last.x1, idx: [last.idx, ...last.extra ?? []] }],
696
+ { text: c.run.text, x0: c.x0, x1: c.x1, idx: [c.idx, ...c.extra ?? []] }
697
+ ];
684
698
  last.x1 = Math.max(last.x1, c.x1);
685
699
  last.run = { ...last.run, text: `${last.run.text.replace(/\s+$/, "")} ${c.run.text.replace(/^\s+/, "")}`, width: last.x1 - last.x0 };
686
- last.extra = [...last.extra ?? [], c.idx];
700
+ last.extra = [...last.extra ?? [], c.idx, ...c.extra ?? []];
687
701
  } else merged.push({ ...c });
702
+ };
703
+ for (const c of row.cells) {
704
+ if (isCurrencySymbol(c.run.text)) {
705
+ if (pendingSym) merged.push(pendingSym);
706
+ pendingSym = { ...c };
707
+ continue;
708
+ }
709
+ if (pendingSym) {
710
+ const em = c.run.fontSize * PT_TO_MM;
711
+ if (numeric(c.run.text) && c.x0 - pendingSym.x1 <= em * 8) {
712
+ const cell = { ...c, run: { ...c.run, text: `${pendingSym.run.text.trim()} ${c.run.text.trim()}` } };
713
+ cell.extra = [...c.extra ?? [], pendingSym.idx, ...pendingSym.extra ?? []];
714
+ pendingSym = null;
715
+ pushCell(cell);
716
+ continue;
717
+ }
718
+ merged.push(pendingSym);
719
+ pendingSym = null;
720
+ }
721
+ pushCell(c);
688
722
  }
723
+ if (pendingSym) merged.push(pendingSym);
689
724
  row.cells = merged;
690
725
  }
691
726
  return rows;
692
727
  }
693
- function columnBands(rows) {
694
- const cells = rows.flatMap((r) => r.cells);
695
- const sorted = cells.slice().sort((a, b) => a.x0 - b.x0);
728
+ var overlaps = (c, b) => c.x0 < b.x1 - 0.2 && c.x1 > b.x0 + 0.2;
729
+ function clusterCells(cells) {
730
+ const sorted = cells.slice().sort((a, b) => a.c.x0 - b.c.x0);
696
731
  const bands = [];
697
- for (const c of sorted) {
732
+ for (const m of sorted) {
698
733
  const last = bands[bands.length - 1];
699
- if (last && c.x0 <= last.x1 - 0.2) {
700
- last.x1 = Math.max(last.x1, c.x1);
701
- last.members.push(c);
702
- } else bands.push({ x0: c.x0, x1: c.x1, members: [c] });
734
+ if (last && m.c.x0 <= last.x1 - 0.2) {
735
+ last.x1 = Math.max(last.x1, m.c.x1);
736
+ last.members.push(m);
737
+ } else bands.push({ x0: m.c.x0, x1: m.c.x1, members: [m] });
703
738
  }
739
+ return bands;
740
+ }
741
+ function hasConflict(bands) {
704
742
  for (const b of bands) {
705
- const rowsSeen = /* @__PURE__ */ new Set();
743
+ const seen = /* @__PURE__ */ new Set();
706
744
  for (const m of b.members) {
707
- const row = rows.find((r) => r.cells.includes(m));
708
- if (rowsSeen.has(row)) return null;
709
- rowsSeen.add(row);
745
+ if (seen.has(m.r)) return true;
746
+ seen.add(m.r);
747
+ }
748
+ }
749
+ return false;
750
+ }
751
+ function columnBands(rows) {
752
+ const all = rows.flatMap((r) => r.cells.map((c) => ({ c, r })));
753
+ if (!all.length) return null;
754
+ const firstOf = new Set(rows.map((r) => r.cells[0]));
755
+ const others = all.filter((m) => !firstOf.has(m.c));
756
+ const othersMin = others.length ? Math.min(...others.map((m) => m.c.x0)) : Infinity;
757
+ const leftOf = (m) => m.c.x0 < othersMin - Math.max(1, m.c.run.fontSize * PT_TO_MM * 1.5);
758
+ const textLabels = all.filter((m) => firstOf.has(m.c) && leftOf(m) && !numeric(m.c.run.text));
759
+ const labelX = textLabels.length ? Math.min(...textLabels.map((m) => m.c.x0)) : Infinity;
760
+ const isLabel = (m) => firstOf.has(m.c) && leftOf(m) && (!numeric(m.c.run.text) || m.c.x0 <= labelX + m.c.run.fontSize * PT_TO_MM * 3);
761
+ const body = all.filter((m) => !isLabel(m));
762
+ const labels = all.filter(isLabel);
763
+ if (!body.length) return null;
764
+ const assign = /* @__PURE__ */ new Map();
765
+ const countOf = /* @__PURE__ */ new Map();
766
+ for (const m of body) countOf.set(m.r, (countOf.get(m.r) ?? 0) + 1);
767
+ const maxCount = Math.max(...countOf.values());
768
+ const anchorRows = new Set(rows.filter((r) => countOf.get(r) === maxCount));
769
+ let bands = clusterCells(body.filter((m) => anchorRows.has(m.r)));
770
+ if (hasConflict(bands)) {
771
+ const first = rows.find((r) => anchorRows.has(r));
772
+ bands = clusterCells(body.filter((m) => m.r === first));
773
+ if (hasConflict(bands)) return null;
774
+ }
775
+ for (const m of body) {
776
+ if (anchorRows.has(m.r)) continue;
777
+ let bi = -1, bestOv = 0;
778
+ bands.forEach((b, k) => {
779
+ if (b.members.some((o) => o.r === m.r)) return;
780
+ const ov = Math.min(m.c.x1, b.x1) - Math.max(m.c.x0, b.x0);
781
+ if (ov > bestOv) {
782
+ bestOv = ov;
783
+ bi = k;
784
+ }
785
+ });
786
+ if (bi < 0) {
787
+ const w = m.c.x1 - m.c.x0;
788
+ if (bands.some((b) => Math.min(m.c.x1, b.x1) - Math.max(m.c.x0, b.x0) > w * 0.5)) return null;
789
+ bands.push({ x0: m.c.x0, x1: m.c.x1, members: [m] });
790
+ continue;
710
791
  }
792
+ bands[bi].members.push(m);
793
+ const touched = bands.filter((b) => overlaps(m.c, b)).length;
794
+ if (touched <= 1) {
795
+ bands[bi].x0 = Math.min(bands[bi].x0, m.c.x0);
796
+ bands[bi].x1 = Math.max(bands[bi].x1, m.c.x1);
797
+ }
798
+ }
799
+ bands.sort((a, b) => a.x0 - b.x0);
800
+ for (let k = 0; k + 1 < bands.length; ) {
801
+ const a = bands[k], b = bands[k + 1];
802
+ const shareRow = a.members.some((m) => b.members.some((o) => o.r === m.r));
803
+ const em = Math.min(...[...a.members, ...b.members].map((m) => m.c.run.fontSize)) * PT_TO_MM;
804
+ if (!shareRow && b.x0 - a.x1 <= em * 1.5) {
805
+ bands.splice(k, 2, { x0: a.x0, x1: Math.max(a.x1, b.x1), members: [...a.members, ...b.members] });
806
+ } else k++;
711
807
  }
712
- return bands.map(({ x0, x1 }) => ({ x0, x1 }));
808
+ const out = [];
809
+ if (labels.length) {
810
+ const lx0 = Math.min(...labels.map((m) => m.c.x0));
811
+ const lx1 = Math.min(Math.max(...labels.map((m) => m.c.x1)), bands[0].x0 - 0.2);
812
+ out.push({ x0: lx0, x1: Math.max(lx1, lx0 + 1) });
813
+ for (const m of labels) assign.set(m.c, 0);
814
+ }
815
+ const off = out.length;
816
+ bands.forEach((b, k) => {
817
+ out.push({ x0: b.x0, x1: b.x1 });
818
+ for (const m of b.members) assign.set(m.c, k + off);
819
+ });
820
+ return { bands: out, assign };
713
821
  }
714
- var numeric = (s) => /^[\s$€£¥+\-−–]*[\d.,]+\s*(%|ms|s|k|m|b|M|K|B|x|×)?\s*(\/\w+)?$/i.test(s.trim()) || /^[+\-−]?\d/.test(s.trim()) && /\d$/.test(s.trim().replace(/[%)]$/, ""));
715
- function detectTables(runs, shapes, pageWidthMm) {
822
+ var dbg = (...a) => {
823
+ if (typeof process !== "undefined" && process.env?.JDF_TABLE_DEBUG) console.error("[tables]", ...a);
824
+ };
825
+ function detectTables(runs, shapes, pageWidthMm, gutters = []) {
716
826
  const out = [];
717
827
  const used = /* @__PURE__ */ new Set();
718
828
  const rows = groupRows(runs, () => false);
719
829
  const pageW = pageWidthMm;
830
+ const emOf = (c) => c.run.fontSize * PT_TO_MM;
720
831
  let i = 0;
721
832
  while (i < rows.length) {
722
833
  if (rows[i].cells.length < 2) {
723
834
  i++;
724
835
  continue;
725
836
  }
837
+ dbg(`block from "${rows[i].cells.map((c) => c.run.text.slice(0, 14)).join(" | ")}"`);
726
838
  let j = i;
727
839
  let cur = columnBands([rows[i]]);
728
840
  let best = null;
841
+ let loneStreak = 0;
729
842
  while (j + 1 < rows.length && cur) {
730
843
  const next = rows[j + 1];
731
844
  const gap = next.y - (rows[j].y + rows[j].h);
732
845
  const rowH = Math.max(rows[j].h, next.h);
733
- if (gap > rowH * 2.2) break;
846
+ const established = !!best && best.j - i >= 2;
847
+ if (gap > rowH * (established ? 3.4 : 2.2)) {
848
+ dbg(`break: gap ${gap.toFixed(1)} after "${rows[j].cells[0].run.text.slice(0, 30)}"`);
849
+ break;
850
+ }
851
+ const bands2 = cur.bands;
734
852
  if (next.cells.length === 1) {
735
853
  const c = next.cells[0];
736
- const inBand = cur.findIndex((b) => c.x0 < b.x1 - 0.2 && c.x1 > b.x0 + 0.2);
737
- const spansSeveral = cur.filter((b) => c.x0 < b.x1 - 0.2 && c.x1 > b.x0 + 0.2).length > 1;
738
- if (inBand <= 0 || spansSeveral) break;
739
- j++;
740
- continue;
854
+ const inBands = bands2.filter((b) => overlaps(c, b));
855
+ const inBand = inBands.length ? bands2.indexOf(inBands[0]) : -1;
856
+ const spansSeveral = inBands.length > 1;
857
+ if (inBand > 0 && !spansSeveral) {
858
+ j++;
859
+ continue;
860
+ }
861
+ const isLabel = c.x1 <= bands2[bands2.length - 1].x0 - 0.5 && (c.x0 <= bands2[0].x0 + emOf(c) * 3 || c.x1 < bands2[0].x0 - 0.5);
862
+ if (isLabel && loneStreak < 4) {
863
+ loneStreak++;
864
+ j++;
865
+ continue;
866
+ }
867
+ dbg(`break: lone line "${c.run.text.slice(0, 40)}" inBand=${inBand} spans=${spansSeveral} label=${isLabel} streak=${loneStreak}`);
868
+ break;
741
869
  }
870
+ loneStreak = 0;
742
871
  const nb = columnBands(rows.slice(i, j + 2));
743
- if (!nb || nb.length < 2) break;
744
- if (nb.length > cur.length && j - i >= 2) break;
872
+ if (!nb || nb.bands.length < 2) {
873
+ dbg(`break: no bands at "${next.cells.map((c) => c.run.text.slice(0, 15)).join(" | ")}"`);
874
+ break;
875
+ }
876
+ if (nb.bands.length > bands2.length && j - i >= 2) {
877
+ const fresh = nb.bands.filter((b) => !bands2.some((o) => overlaps(b, o)) && b.x0 > bands2[0].x0);
878
+ if (fresh.some((b) => b.x1 - b.x0 > 20)) {
879
+ dbg(`break: wide fresh band at "${next.cells.map((c) => c.run.text.slice(0, 15)).join(" | ")}"`);
880
+ break;
881
+ }
882
+ }
745
883
  cur = nb;
746
884
  j++;
747
- if (cur.length >= 2) best = { j, bands: cur };
885
+ if (cur.bands.length >= 2) best = { j, res: cur };
748
886
  }
749
- const multiRows = best ? rows.slice(i, best.j + 1).filter((r) => r.cells.length >= 2).length : 0;
750
887
  const blockRows = best ? rows.slice(i, best.j + 1) : [];
888
+ const multiRows = blockRows.filter((r) => r.cells.length >= 2).length;
751
889
  const bbox = blockRows.length ? {
752
- x0: Math.min(...best.bands.map((b) => b.x0)) - 5,
753
- x1: Math.max(...best.bands.map((b) => b.x1)) + 5,
890
+ x0: Math.min(...best.res.bands.map((b) => b.x0)) - 5,
891
+ x1: Math.max(...best.res.bands.map((b) => b.x1)) + 5,
754
892
  // Cell padding puts backgrounds/borders well above the first baseline and below the last.
755
893
  y0: blockRows[0].y - blockRows[0].h * 2.5,
756
894
  y1: blockRows[blockRows.length - 1].y + blockRows[blockRows.length - 1].h * 3
757
895
  } : null;
758
896
  const gridShapes = bbox ? shapes.map((s, k) => ({ s, k })).filter(({ s }) => s.x >= bbox.x0 - 1 && s.x + s.width <= bbox.x1 + 1 && s.y >= bbox.y0 - 1 && s.y + s.height <= bbox.y1 + 1 && (s.kind === "line" || s.kind === "rect")) : [];
759
- const hasLattice = gridShapes.length >= 3;
760
- if (!best || multiRows < (hasLattice ? 2 : 3) || best.bands.length < 2) {
897
+ const tableW = bbox ? bbox.x1 - bbox.x0 : 0;
898
+ const isRule = (s) => s.kind === "line" || s.kind === "rect" && (s.height < 0.6 || s.width < 0.6) && !!(s.fill || s.stroke);
899
+ const ruleYs = gridShapes.filter(({ s }) => isRule(s) && s.width >= s.height && s.width >= tableW * 0.45).map(({ s }) => s.y + s.height / 2).sort((a, b) => a - b);
900
+ const verticalRules = blockRows.length ? gridShapes.filter(({ s }) => isRule(s) && s.height > s.width && s.height >= blockRows[0].h * 1.5).length : 0;
901
+ const rowFill = (row) => {
902
+ const cy = row.y + row.h * 0.5;
903
+ const rects = gridShapes.filter(({ s }) => s.kind === "rect" && s.fill && s.fill.toLowerCase() !== "#ffffff" && s.height >= row.h * 0.6 && s.height < row.h * 4.5 && s.y <= cy && s.y + s.height >= cy);
904
+ const covered = rects.reduce((a, { s }) => a + s.width, 0);
905
+ if (!rects.length || covered < tableW * 0.5) return null;
906
+ const counts2 = /* @__PURE__ */ new Map();
907
+ for (const { s } of rects) counts2.set(s.fill, (counts2.get(s.fill) ?? 0) + s.width);
908
+ const fill = [...counts2.entries()].sort((a, b) => b[1] - a[1])[0][0];
909
+ return { fill, rects };
910
+ };
911
+ const filledRows = blockRows.filter((r) => rowFill(r)).length;
912
+ const hasLattice = verticalRules >= 2 || ruleYs.length >= 3 || filledRows >= 2;
913
+ if (!best || multiRows < (hasLattice ? 2 : 3) || best.res.bands.length < 2) {
914
+ if (best) dbg(`reject: ${multiRows} multi rows from "${rows[i].cells[0].run.text.slice(0, 30)}"`);
761
915
  i++;
762
916
  continue;
763
917
  }
764
- const bands = best.bands;
765
- const cellText = (row, b) => row.cells.filter((c) => c.x0 < bands[b].x1 - 0.2 && c.x1 > bands[b].x0 + 0.2).map((c) => c.run.text.trim()).join(" ").trim();
918
+ if (gutters.length && !hasLattice) {
919
+ const b = best.res.bands;
920
+ const straddles = gutters.some((g) => b.some((band) => band.x1 <= g.x1 + 1 && band.x1 - band.x0 > 30) && b.some((band) => band.x0 >= g.x0 - 1 && band.x1 - band.x0 > 30));
921
+ if (straddles) {
922
+ dbg("reject: straddles a column gutter");
923
+ i++;
924
+ continue;
925
+ }
926
+ }
927
+ const { bands, assign } = best.res;
928
+ const ruleBetween = (a, b) => {
929
+ const y02 = a.y + a.h * 0.5, y1 = b.y + b.h * 0.5;
930
+ return ruleYs.some((y) => y > y02 && y < y1);
931
+ };
932
+ const denseRules = ruleYs.length >= Math.max(2, multiRows * 0.6);
933
+ for (const row of blockRows) {
934
+ for (const c of row.cells.slice()) {
935
+ const b = assign.get(c);
936
+ if (b == null || b + 1 >= bands.length) continue;
937
+ if (row.cells.some((o) => o !== c && assign.get(o) === b + 1)) continue;
938
+ const nextX0 = bands[b + 1].x0;
939
+ let cut;
940
+ if (!c.parts) {
941
+ const w = c.x1 - c.x0;
942
+ if (c.x1 <= nextX0 + w * 0.3 || !/\s/.test(c.run.text.trim())) continue;
943
+ const text = c.run.text.trim();
944
+ const at = Math.round((nextX0 - c.x0) / w * text.length);
945
+ let best2 = -1;
946
+ for (let q = 0; q < text.length; q++) if (text[q] === " " && (best2 < 0 || Math.abs(q - at) < Math.abs(best2 - at))) best2 = q;
947
+ if (best2 <= 0 || best2 >= text.length - 1 || Math.abs(best2 - at) > text.length * 0.25) continue;
948
+ const perChar = w / text.length;
949
+ c.parts = [
950
+ { text: text.slice(0, best2), x0: c.x0, x1: c.x0 + best2 * perChar, idx: [c.idx, ...c.extra ?? []] },
951
+ { text: text.slice(best2 + 1), x0: c.x0 + (best2 + 1) * perChar, x1: c.x1, idx: [c.idx] }
952
+ ];
953
+ cut = 1;
954
+ } else {
955
+ cut = c.parts.findIndex((p) => p.x0 >= nextX0 - 0.5);
956
+ }
957
+ if (cut <= 0) continue;
958
+ const head = c.parts.slice(0, cut), tail = c.parts.slice(cut);
959
+ const tailCell = { run: { ...c.run, text: tail.map((p) => p.text.trim()).join(" ") }, idx: tail[0].idx[0], x0: tail[0].x0, x1: tail[tail.length - 1].x1, parts: tail };
960
+ tailCell.extra = tail.flatMap((p) => p.idx).filter((k) => k !== tailCell.idx);
961
+ c.run = { ...c.run, text: head.map((p) => p.text.trim()).join(" ") };
962
+ c.x1 = head[head.length - 1].x1;
963
+ c.idx = head[0].idx[0];
964
+ c.extra = head.flatMap((p) => p.idx).filter((k) => k !== c.idx);
965
+ c.parts = head.length > 1 ? head : void 0;
966
+ row.cells.push(tailCell);
967
+ assign.set(tailCell, b + 1);
968
+ }
969
+ row.cells.sort((a, b2) => a.x0 - b2.x0);
970
+ }
971
+ const clean = (t) => t.replace(/(?:\s*\.){3,}\s*$/, "").replace(/\s+/g, " ").trim();
972
+ const cellText = (row, b) => clean(row.cells.filter((c) => assign.get(c) === b).map((c) => c.run.text.trim()).join(" "));
973
+ const cellIdx = (c) => [c.idx, ...c.extra ?? []];
766
974
  const grid = [];
975
+ const gridRows = [];
767
976
  const lineIdx = [];
768
- for (const row of blockRows) {
769
- if (row.cells.length === 1 && grid.length) {
977
+ const pitches = [];
978
+ for (let r = 1; r < blockRows.length; r++) pitches.push(blockRows[r].y - blockRows[r - 1].y);
979
+ const medPitch = pitches.length ? pitches.slice().sort((a, b) => a - b)[Math.floor(pitches.length / 2)] : blockRows[0].h * 1.3;
980
+ let pendingLabel = null;
981
+ const flushPending = () => {
982
+ if (!pendingLabel) return;
983
+ const cells = bands.map(() => "");
984
+ cells[0] = pendingLabel.text;
985
+ grid.push(cells);
986
+ gridRows.push(pendingLabel.row);
987
+ lineIdx.push(...pendingLabel.idx);
988
+ pendingLabel = null;
989
+ };
990
+ for (let r = 0; r < blockRows.length; r++) {
991
+ const row = blockRows[r];
992
+ if (row.cells.length === 1 && grid.length + (pendingLabel ? 1 : 0) > 0) {
770
993
  const c = row.cells[0];
771
- const b = bands.findIndex((bb) => c.x0 < bb.x1 - 0.2 && c.x1 > bb.x0 + 0.2);
994
+ const b = assign.get(c) ?? bands.findIndex((bb) => overlaps(c, bb));
772
995
  if (b > 0) {
996
+ flushPending();
773
997
  grid[grid.length - 1][b] = (grid[grid.length - 1][b] + " " + c.run.text.trim()).trim();
774
- lineIdx.push(c.idx, ...c.extra ?? []);
998
+ lineIdx.push(...cellIdx(c));
999
+ continue;
1000
+ }
1001
+ const text = clean(c.run.text);
1002
+ const prev = gridRows[gridRows.length - 1];
1003
+ const nextRow = blockRows[r + 1];
1004
+ const nextLabel = nextRow && nextRow.cells.length >= 2 ? cellText(nextRow, 0) : "";
1005
+ const lower = /^[(\[a-zà-ÿ]/.test(text);
1006
+ const attachPrev = !pendingLabel && prev && grid.length && (denseRules ? !ruleBetween(prev, row) : lower);
1007
+ if (attachPrev) {
1008
+ grid[grid.length - 1][0] = (grid[grid.length - 1][0] + " " + text).trim();
1009
+ lineIdx.push(...cellIdx(c));
775
1010
  continue;
776
1011
  }
1012
+ const attachNext = nextLabel && (denseRules ? !ruleBetween(row, nextRow) : /^[a-zà-ÿ]/.test(nextLabel) || !/[.:]$/.test(text) && row.cells[0].x0 <= bands[0].x0 + emOf(c) * 0.5 && nextRow.y - row.y <= medPitch * 1.25);
1013
+ if (pendingLabel) {
1014
+ pendingLabel.text = `${pendingLabel.text} ${text}`;
1015
+ pendingLabel.idx.push(...cellIdx(c));
1016
+ } else pendingLabel = { text, idx: cellIdx(c), row };
1017
+ if (!attachNext) flushPending();
1018
+ continue;
1019
+ }
1020
+ const prevRow = gridRows[gridRows.length - 1];
1021
+ if (prevRow && !pendingLabel && row.y - prevRow.y > medPitch * 1.7 && grid.length) {
1022
+ grid.push(bands.map(() => ""));
1023
+ gridRows.push(row);
777
1024
  }
778
- grid.push(bands.map((_, b) => cellText(row, b)));
779
- for (const c of row.cells) {
780
- lineIdx.push(c.idx);
781
- for (const k of c.extra ?? []) lineIdx.push(k);
1025
+ const cells = bands.map((_, b) => cellText(row, b));
1026
+ if (pendingLabel) {
1027
+ cells[0] = `${pendingLabel.text} ${cells[0]}`.trim();
1028
+ lineIdx.push(...pendingLabel.idx);
1029
+ pendingLabel = null;
782
1030
  }
1031
+ grid.push(cells);
1032
+ gridRows.push(row);
1033
+ for (const c of row.cells) lineIdx.push(...cellIdx(c));
1034
+ }
1035
+ flushPending();
1036
+ for (let r = 1; r < grid.length; r++) {
1037
+ const cur2 = grid[r], prev = grid[r - 1];
1038
+ if (cur2[0] !== "" || !prev[0] || !prev.some(Boolean) || !cur2.some(Boolean)) continue;
1039
+ const last = bands.length - 1;
1040
+ if (prev[last] !== "" || cur2[last] === "") continue;
1041
+ if (prev.slice(1).some((v, k) => v && cur2[k + 1] && numeric(v) && numeric(cur2[k + 1]))) continue;
1042
+ for (let k = 0; k < bands.length; k++) prev[k] = [prev[k], cur2[k]].filter(Boolean).join(" ");
1043
+ grid.splice(r, 1);
1044
+ gridRows.splice(r, 1);
1045
+ r--;
1046
+ }
1047
+ while (grid.length && grid[grid.length - 1].every((c) => !c)) {
1048
+ grid.pop();
1049
+ gridRows.pop();
783
1050
  }
784
1051
  if (lineIdx.some((k) => used.has(k))) {
785
1052
  i = best.j + 1;
786
1053
  continue;
787
1054
  }
1055
+ const isNumCol = bands.map((_, k) => {
1056
+ const vals = grid.map((r) => r[k]).filter(Boolean);
1057
+ const n = vals.filter(numeric).length;
1058
+ return n >= 2 && n / vals.length >= 0.6;
1059
+ });
1060
+ const numCols = isNumCol.filter(Boolean).length;
1061
+ const hasLoneLabel = blockRows.some((r) => r.cells.length === 1 && (assign.get(r.cells[0]) ?? -1) === 0);
1062
+ const structural = verticalRules >= 2 || filledRows >= 2 || ruleYs.length >= 3 && numCols > 0;
1063
+ const counts = new Set(blockRows.filter((r) => r.cells.length >= 2).map((r) => r.cells.length));
1064
+ if (!structural && numCols === 0 && (multiRows < 4 || hasLoneLabel || counts.size > 1 || bands.length === 2 && grid.length <= 3)) {
1065
+ dbg("reject text block", grid[0]);
1066
+ i++;
1067
+ continue;
1068
+ }
788
1069
  const first = blockRows[0];
789
- const tableW = bbox.x1 - bbox.x0;
790
- const rowFill = (row) => {
791
- const cy = row.y + row.h * 0.5;
792
- const rects = gridShapes.filter(({ s }) => s.kind === "rect" && s.fill && s.fill.toLowerCase() !== "#ffffff" && s.height >= row.h * 0.6 && s.height < row.h * 4.5 && s.y <= cy && s.y + s.height >= cy);
793
- const covered = rects.reduce((a, { s }) => a + s.width, 0);
794
- if (!rects.length || covered < tableW * 0.5) return null;
795
- const counts = /* @__PURE__ */ new Map();
796
- for (const { s } of rects) counts.set(s.fill, (counts.get(s.fill) ?? 0) + s.width);
797
- const fill = [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0];
798
- return { fill, rects };
799
- };
800
1070
  const headerBg = rowFill(first);
801
1071
  const firstBold = first.cells.every((c) => c.run.bold);
802
1072
  const bodyFills = blockRows.slice(1).map(rowFill);
803
1073
  const headerDistinct = !!headerBg && !bodyFills.every((f) => f?.fill === headerBg.fill);
804
- const isHeader = headerDistinct || firstBold && !blockRows.slice(1).every((r) => r.cells.every((c) => c.run.bold));
1074
+ let headerRows = headerDistinct || firstBold && !blockRows.slice(1).every((r) => r.cells.every((c) => c.run.bold)) ? 1 : 0;
1075
+ const rowBold = (r) => r.cells.every((c) => c.run.bold);
1076
+ if (!blockRows.every(rowBold)) {
1077
+ let b = 0;
1078
+ while (b < Math.min(5, gridRows.length - 1) && gridRows[b] && rowBold(gridRows[b]) && grid[b].some(Boolean)) b++;
1079
+ headerRows = Math.max(headerRows, b);
1080
+ }
1081
+ if (denseRules && ruleYs.length) {
1082
+ const firstRule = ruleYs.find((y) => y > gridRows[0].y + gridRows[0].h * 0.5);
1083
+ if (firstRule != null) {
1084
+ const above = gridRows.filter((r) => r.y + r.h * 0.5 < firstRule).length;
1085
+ if (above >= 1 && above <= 5 && gridRows.length - above >= 2) headerRows = Math.max(headerRows, above);
1086
+ }
1087
+ }
1088
+ if (numCols > 0) {
1089
+ let h = 0;
1090
+ while (h < Math.min(5, grid.length - 2)) {
1091
+ const row = grid[h];
1092
+ const nonEmpty = row.filter(Boolean);
1093
+ if (!nonEmpty.length) break;
1094
+ const numericHere = row.some((v, k) => isNumCol[k] && v && numeric(v));
1095
+ if (numericHere) break;
1096
+ if (!row.some((v, k) => isNumCol[k] && v) && h >= headerRows) break;
1097
+ h++;
1098
+ }
1099
+ headerRows = Math.max(headerRows, h);
1100
+ }
1101
+ if (headerRows > 0 && grid.length - headerRows < 1) headerRows = 0;
805
1102
  const columns = bands.map((b, k) => {
806
- const vals = grid.slice(isHeader ? 1 : 0).map((r) => r[k]).filter(Boolean);
807
- const numericShare = vals.length ? vals.filter(numeric).length / vals.length : 0;
808
1103
  const col = { width: Math.round((b.x1 - b.x0) * 10) / 10 };
809
- if (numericShare >= 0.7) col.align = "right";
1104
+ if (isNumCol[k]) col.align = "right";
810
1105
  return col;
811
1106
  });
812
1107
  const x0 = Math.max(0, bands[0].x0 - 2.5);
813
1108
  const x1 = Math.min(pageW, bands[bands.length - 1].x1 + 2.5);
814
- for (let k = 0; k < bands.length; k++) {
815
- const left = k === 0 ? x0 : (bands[k - 1].x1 + bands[k].x0) / 2;
816
- const right = k === bands.length - 1 ? x1 : (bands[k].x1 + bands[k + 1].x0) / 2;
817
- columns[k].width = Math.round((right - left) * 10) / 10;
818
- }
819
- const rowFills = (isHeader ? bodyFills : [headerBg, ...bodyFills]).map((f) => f?.fill ?? null);
1109
+ const edges = [x0];
1110
+ for (let k = 1; k < bands.length; k++) edges.push(Math.max(edges[k - 1] + 2, Math.min((bands[k - 1].x1 + bands[k].x0) / 2, x1 - 2 * (bands.length - k))));
1111
+ edges.push(Math.max(edges[edges.length - 1] + 2, x1));
1112
+ for (let k = 0; k < bands.length; k++) columns[k].width = Math.round((edges[k + 1] - edges[k]) * 10) / 10;
1113
+ const bodyRows = gridRows.slice(headerRows).filter((r, k, arr) => grid[headerRows + k]?.some(Boolean));
1114
+ const rowFills = bodyRows.map(rowFill).map((f) => f?.fill ?? null);
820
1115
  const odd = rowFills.filter((_, k) => k % 2 === 1), even = rowFills.filter((_, k) => k % 2 === 0);
821
- const altColor = odd.length && odd[0] && odd.every((f) => f === odd[0]) && even.every((f) => f !== odd[0]) ? odd[0] : void 0;
822
- const borderShape = gridShapes.find(({ s }) => s.kind === "line" || s.kind === "rect" && (s.height < 0.6 || s.width < 0.6) && (s.fill || s.stroke));
1116
+ const oddAlt = odd.length && odd[0] && odd.every((f) => f === odd[0]) && even.every((f) => f !== odd[0]) ? odd[0] : void 0;
1117
+ const evenAlt = !oddAlt && even.length > 1 && even[0] && even.every((f) => f === even[0]) && odd.every((f) => f !== even[0]) ? even[0] : void 0;
1118
+ const altColor = oddAlt || evenAlt;
1119
+ const borderShape = gridShapes.find(({ s }) => isRule(s));
823
1120
  const borderColor = borderShape ? borderShape.s.stroke || borderShape.s.fill : void 0;
824
- const fontSize = Math.round(first.cells[0].run.fontSize * 10) / 10;
825
- const y0 = headerBg ? Math.min(...headerBg.rects.map(({ s }) => s.y)) : first.y - first.h * 0.5;
1121
+ const hasGrid = verticalRules >= 2;
1122
+ const sizeChars = /* @__PURE__ */ new Map();
1123
+ for (const row of blockRows) for (const c of row.cells) {
1124
+ const k = Math.round(c.run.fontSize * 2) / 2;
1125
+ sizeChars.set(k, (sizeChars.get(k) ?? 0) + c.run.text.length);
1126
+ }
1127
+ const fontSize = [...sizeChars.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? Math.round(first.cells[0].run.fontSize * 10) / 10;
1128
+ const lineMm = fontSize * PT_TO_MM * 1.2;
1129
+ const padV = Math.max(0.2, Math.min(2.5, (medPitch - lineMm) / 2));
1130
+ const padH = Math.max(0.5, Math.min(3, fontSize * PT_TO_MM * 0.35));
1131
+ const y0 = headerBg ? Math.min(...headerBg.rects.map(({ s }) => s.y)) : first.y - padV;
826
1132
  const element = {
827
1133
  type: "table",
828
1134
  position: { x: Math.round(x0 * 100) / 100, y: Math.round(Math.max(0, y0) * 100) / 100 },
829
1135
  width: Math.round((x1 - x0) * 100) / 100,
830
1136
  columns,
831
- rows: isHeader ? grid.slice(1) : grid,
832
- style: { fontSize }
1137
+ rows: (() => {
1138
+ const body = grid.slice(headerRows);
1139
+ while (body.length > 1 && body[0].every((c) => !c)) body.shift();
1140
+ return body;
1141
+ })(),
1142
+ style: { fontSize, lineHeight: 1.2, padding: `${Math.round(padV * 100) / 100}mm ${Math.round(padH * 100) / 100}mm` }
833
1143
  };
834
- if (isHeader) {
835
- element.headers = grid[0];
1144
+ if (headerRows > 0) {
1145
+ element.headers = bands.map((_, k) => grid.slice(0, headerRows).map((r) => r[k]).filter(Boolean).join(" ").trim());
836
1146
  const hs = { fontWeight: "bold" };
837
1147
  if (headerBg) hs.backgroundColor = headerBg.fill;
838
- const hc = first.cells[0].run.color;
839
- if (hc && hc !== "#000000") hs.color = hc;
1148
+ if (headerBg) {
1149
+ const tally = /* @__PURE__ */ new Map();
1150
+ for (const c of blockRows.slice(0, headerRows).flatMap((r) => r.cells)) tally.set(c.run.color, (tally.get(c.run.color) ?? 0) + c.run.text.length);
1151
+ const hc = [...tally.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
1152
+ if (hc && hc !== "#000000") hs.color = hc;
1153
+ }
840
1154
  element.headerStyle = hs;
841
1155
  }
842
- if (altColor) element.alternatingRowColor = altColor;
843
- element.borders = borderColor ? { outer: true, inner: true, color: borderColor, width: 1 } : false;
1156
+ if (oddAlt) element.alternatingRowColor = oddAlt;
1157
+ if (evenAlt) {
1158
+ element.rowStyle = { backgroundColor: evenAlt };
1159
+ element.alternateRowStyle = { backgroundColor: "#ffffff" };
1160
+ }
1161
+ element.borders = hasGrid && borderColor ? { outer: true, inner: true, color: borderColor, width: 1 } : false;
1162
+ const fillRects = /* @__PURE__ */ new Set();
1163
+ if (headerBg) for (const { k } of headerBg.rects) fillRects.add(k);
1164
+ if (headerRows > 0) {
1165
+ const hy0 = gridRows[0].y - gridRows[0].h * 0.5, hy1 = gridRows[headerRows - 1].y + gridRows[headerRows - 1].h * 1.2;
1166
+ for (const { s, k } of gridShapes) if (s.kind === "rect" && s.fill && s.fill.toLowerCase() !== "#ffffff" && !isRule(s) && s.y < hy1 && s.y + s.height > hy0 && s.y >= hy0 - gridRows[0].h) fillRects.add(k);
1167
+ }
1168
+ if (altColor) for (const row of blockRows) {
1169
+ const f = rowFill(row);
1170
+ if (f) for (const { k } of f.rects) fillRects.add(k);
1171
+ }
1172
+ const consumedShapes = gridShapes.filter(({ s, k }) => isRule(s) || fillRects.has(k)).map(({ k }) => k);
1173
+ dbg(`table ${grid.length} rows \xD7 ${bands.length} cols at y=${element.position.y}`, element.headers ?? grid[0]);
844
1174
  for (const k of lineIdx) used.add(k);
845
- out.push({ element, lineIdx, shapeIdx: gridShapes.map(({ k }) => k) });
1175
+ out.push({ element, lineIdx, shapeIdx: consumedShapes });
846
1176
  i = best.j + 1;
847
1177
  }
848
1178
  return out;
849
1179
  }
850
1180
 
851
- // ../../packages/jdf-pdf-import/src/core.ts
1181
+ // ../../packages/jdf-pdf-import/src/columns.ts
1182
+ function detectGutters(lines, bodyFontSize, pageWmm) {
1183
+ const body = lines.filter((l) => l.text.trim().length > 0 && Math.abs(l.fontSize - bodyFontSize) <= 1.5 && l.width < pageWmm * 0.6 && l.width > 0);
1184
+ if (body.length < 12) return [];
1185
+ const minX = Math.min(...body.map((l) => l.x));
1186
+ const maxX = Math.max(...body.map((l) => l.x + l.width));
1187
+ const span = maxX - minX;
1188
+ if (span < pageWmm * 0.4) return [];
1189
+ const BIN = 0.5;
1190
+ const n = Math.ceil(span / BIN) + 1;
1191
+ const cov = new Array(n).fill(0);
1192
+ for (const l of body) {
1193
+ const a = Math.max(0, Math.floor((l.x - minX) / BIN)), b = Math.min(n, Math.ceil((l.x + l.width - minX) / BIN));
1194
+ for (let i2 = a; i2 < b; i2++) cov[i2]++;
1195
+ }
1196
+ const noise = Math.max(2, Math.round(body.length * 0.05));
1197
+ const gutters = [];
1198
+ let i = 0;
1199
+ while (i < n) {
1200
+ if (cov[i] > noise) {
1201
+ i++;
1202
+ continue;
1203
+ }
1204
+ let j = i;
1205
+ while (j < n && cov[j] <= noise) j++;
1206
+ const g0 = minX + i * BIN, g1 = minX + j * BIN;
1207
+ if (g1 - g0 >= 3 && g0 > minX + span * 0.15 && g1 < maxX - span * 0.15) gutters.push({ x0: g0, x1: g1 });
1208
+ i = j;
1209
+ }
1210
+ const solid = gutters.every((g) => body.filter((l) => l.x + l.width <= g.x0 + 0.5).length >= 4 && body.filter((l) => l.x >= g.x1 - 0.5).length >= 4);
1211
+ return solid ? gutters : [];
1212
+ }
1213
+ function orderByColumns(els, gutters, pageWmm) {
1214
+ if (!gutters.length || els.length < 2) return els;
1215
+ const bands = [];
1216
+ let left = 0;
1217
+ for (const g of gutters) {
1218
+ bands.push({ x0: left, x1: g.x0 });
1219
+ left = g.x1;
1220
+ }
1221
+ bands.push({ x0: left, x1: pageWmm });
1222
+ const colOf = (e) => {
1223
+ const x = e.position?.x ?? 0;
1224
+ const k = bands.findIndex((b) => x >= b.x0 && x < b.x1);
1225
+ if (k >= 0) return k;
1226
+ let best = 0, d = Infinity;
1227
+ bands.forEach((b, i) => {
1228
+ const dd = Math.min(Math.abs(x - b.x0), Math.abs(x - b.x1));
1229
+ if (dd < d) {
1230
+ d = dd;
1231
+ best = i;
1232
+ }
1233
+ });
1234
+ return best;
1235
+ };
1236
+ const spans = (e) => {
1237
+ const c = colOf(e);
1238
+ if (c >= bands.length - 1) return false;
1239
+ const right = (e.position?.x ?? 0) + (e.width ?? 0);
1240
+ const next = bands[c + 1];
1241
+ return right > (next.x0 + next.x1) / 2;
1242
+ };
1243
+ const sorted = els.slice().sort((a, b) => (a.position?.y ?? 0) - (b.position?.y ?? 0) || (a.position?.x ?? 0) - (b.position?.x ?? 0));
1244
+ const out = [];
1245
+ let band = [];
1246
+ const flush = () => {
1247
+ for (let c = 0; c < bands.length; c++) for (const e of band) if (colOf(e) === c) out.push(e);
1248
+ band = [];
1249
+ };
1250
+ for (const e of sorted) {
1251
+ if (spans(e)) {
1252
+ flush();
1253
+ out.push(e);
1254
+ } else band.push(e);
1255
+ }
1256
+ flush();
1257
+ return out;
1258
+ }
1259
+
1260
+ // ../../packages/jdf-pdf-import/src/paragraphs.ts
852
1261
  var PT_TO_MM2 = 0.352778;
1262
+ var LIST_START = /^\s*(?:[•◦▪●■\-–—*]\s|\(?\d{1,3}[.)]\s|\(?[a-zA-Z][.)]\s|[ivx]{1,4}[.)]\s)/;
1263
+ var TERMINAL = /[.!?:;]["'”’)\]]*$/;
1264
+ function textOf(e) {
1265
+ if (e.type === "text") return String(e.content ?? "");
1266
+ return (e.runs ?? []).map((r) => String(r.text ?? "")).join("");
1267
+ }
1268
+ function foldParagraphs(elements, meta, pageWmm) {
1269
+ const foldable = (e) => !!meta.get(e) && !!e.position && (e.type === "text" && !e.heading || e.type === "richtext");
1270
+ const out = [];
1271
+ let i = 0;
1272
+ while (i < elements.length) {
1273
+ const first = elements[i];
1274
+ if (!foldable(first)) {
1275
+ out.push(first);
1276
+ i++;
1277
+ continue;
1278
+ }
1279
+ const para = [first];
1280
+ let j = i + 1;
1281
+ while (j < elements.length) {
1282
+ const prev = para[para.length - 1], next = elements[j];
1283
+ if (!foldable(next) || !continues(para, prev, next, meta)) break;
1284
+ para.push(next);
1285
+ j++;
1286
+ }
1287
+ out.push(para.length > 1 ? merge(para, meta, pageWmm) : first);
1288
+ i = j;
1289
+ }
1290
+ return out;
1291
+ }
1292
+ function continues(para, prev, next, meta) {
1293
+ const mp = meta.get(prev), mn = meta.get(next); meta.get(para[0]);
1294
+ if (mp.face !== mn.face || Math.abs(mp.size - mn.size) >= 0.5) return false;
1295
+ const lineH = mp.size * PT_TO_MM2;
1296
+ const pitch = next.position.y - prev.position.y;
1297
+ if (pitch < lineH * 0.9 || pitch > lineH * 1.75) return false;
1298
+ const dx = next.position.x - (para.length === 1 ? prev.position.x : para[0].position.x);
1299
+ if (para.length === 1 ? Math.abs(dx) > 6 : Math.abs(dx) > 1.2) return false;
1300
+ if (para.length >= 2 && Math.abs(next.position.x - para[1].position.x) > 1.2) return false;
1301
+ const nText = textOf(next), pText = textOf(prev).trimEnd();
1302
+ if (LIST_START.test(nText) || !nText.trim()) return false;
1303
+ const maxW = Math.max(mp.w, mn.w, ...para.map((e) => meta.get(e).w));
1304
+ const full = mp.w >= maxW * 0.85;
1305
+ const endsHyphen = /[-‐‑]$/.test(pText);
1306
+ const softEnd = !TERMINAL.test(pText);
1307
+ const startsLower = /^[a-z(\[]/.test(nText.trimStart());
1308
+ return full || endsHyphen || softEnd && (startsLower || mp.w >= maxW * 0.6);
1309
+ }
1310
+ function merge(para, meta, pageWmm) {
1311
+ const first = para[0], last = para[para.length - 1];
1312
+ const m0 = meta.get(first);
1313
+ const lineH = m0.size * PT_TO_MM2;
1314
+ const x = Math.min(...para.map((e) => e.position.x));
1315
+ const indent = first.position.x - x;
1316
+ const right = Math.max(...para.map((e) => e.position.x + meta.get(e).w));
1317
+ const pitch = (last.position.y - first.position.y) / (para.length - 1);
1318
+ const width = Math.min(pageWmm - x, (right - x) * 1.2 + lineH * 0.4);
1319
+ const joiner = () => "\n";
1320
+ const style = { ...first.style ?? {}, lineHeight: Math.round(pitch / lineH * 100) / 100, ...indent > 0.5 ? { textIndent: Math.round(indent * 100) / 100 } : {} };
1321
+ const allText = para.every((e) => e.type === "text" && !e.link);
1322
+ const base = { position: { x, y: first.position.y }, width: Math.round(width * 100) / 100, style };
1323
+ if (allText) {
1324
+ const content = para.map((e) => String(e.content ?? "").trim().replace(/[ \t]+/g, " ")).join(joiner());
1325
+ const { height: _hh2, ...firstRest } = first;
1326
+ return { ...firstRest, ...base, content };
1327
+ }
1328
+ const runs = [];
1329
+ for (const e of para) {
1330
+ const lineRuns = e.type === "text" ? [{ text: String(e.content ?? "").trim(), ...e.style?.fontWeight === "bold" ? { bold: true } : {}, ...e.style?.fontStyle === "italic" ? { italic: true } : {}, ...e.style?.color && e.style.color !== "#000000" ? { color: e.style.color } : {}, ...e.link ? { link: e.link } : {} }] : (e.runs ?? []).map((r) => ({ ...r }));
1331
+ if (runs.length) runs[runs.length - 1].text = `${String(runs[runs.length - 1].text).trimEnd()}${joiner()}`;
1332
+ runs.push(...lineRuns);
1333
+ }
1334
+ const { content: _c, heading: _h, link: _l, height: _hh, ...rest } = first;
1335
+ return { ...rest, ...base, type: "richtext", runs, style: { fontSize: style.fontSize, fontFamily: style.fontFamily, lineHeight: style.lineHeight, ...style.textIndent ? { textIndent: style.textIndent } : {}, ...style.opacity != null ? { opacity: style.opacity } : {} } };
1336
+ }
1337
+
1338
+ // ../../packages/jdf-pdf-import/src/core.ts
1339
+ var PT_TO_MM3 = 0.352778;
853
1340
  function classifyFont(name) {
854
1341
  const n = (name || "").toLowerCase();
855
1342
  const bold = /bold|black|heavy|semibold|demibold|extrabold/.test(n);
@@ -875,6 +1362,47 @@ function rgbToHex(r, g, b) {
875
1362
  const h = (n) => clampByte(n).toString(16).padStart(2, "0");
876
1363
  return `#${h(r)}${h(g)}${h(b)}`;
877
1364
  }
1365
+ function mergeCollinearShapes(shapes) {
1366
+ const chain = (input, horizontal) => {
1367
+ const groups = /* @__PURE__ */ new Map();
1368
+ input.forEach((s, k) => {
1369
+ if (s.kind !== "rect" || s.stroke || s.path || !s.fill) return;
1370
+ if (horizontal ? s.height > 1 : s.width > 1) return;
1371
+ const key = horizontal ? `${s.fill}|${s.opacity ?? 1}|${s.y.toFixed(1)}|${s.height.toFixed(1)}` : `${s.fill}|${s.opacity ?? 1}|${s.x.toFixed(1)}|${s.width.toFixed(1)}`;
1372
+ const g = groups.get(key);
1373
+ if (g) g.push({ s, k });
1374
+ else groups.set(key, [{ s, k }]);
1375
+ });
1376
+ const replaceAt = /* @__PURE__ */ new Map();
1377
+ const drop = /* @__PURE__ */ new Set();
1378
+ for (const g of groups.values()) {
1379
+ if (g.length < 2) continue;
1380
+ g.sort((a, b) => horizontal ? a.s.x - b.s.x : a.s.y - b.s.y);
1381
+ let run = [g[0]];
1382
+ const flush = () => {
1383
+ if (run.length >= 2) {
1384
+ const first = run[0].s;
1385
+ const end = Math.max(...run.map(({ s }) => horizontal ? s.x + s.width : s.y + s.height));
1386
+ replaceAt.set(run[0].k, horizontal ? { ...first, width: end - first.x } : { ...first, height: end - first.y });
1387
+ for (let q = 1; q < run.length; q++) drop.add(run[q].k);
1388
+ }
1389
+ };
1390
+ for (let q = 1; q < g.length; q++) {
1391
+ const prev = run[run.length - 1].s, cur = g[q].s;
1392
+ const gap = horizontal ? cur.x - (prev.x + prev.width) : cur.y - (prev.y + prev.height);
1393
+ if (gap <= 0.15) run.push(g[q]);
1394
+ else {
1395
+ flush();
1396
+ run = [g[q]];
1397
+ }
1398
+ }
1399
+ flush();
1400
+ }
1401
+ if (!replaceAt.size) return input;
1402
+ return input.map((s, k) => replaceAt.get(k) ?? s).filter((_, k) => !drop.has(k));
1403
+ };
1404
+ return chain(chain(shapes, true), false);
1405
+ }
878
1406
  function averageStops(stops) {
879
1407
  if (!Array.isArray(stops) || stops.length === 0) return null;
880
1408
  let r = 0, g = 0, b = 0, n = 0;
@@ -959,10 +1487,10 @@ async function walkOps(page, OPS, viewport) {
959
1487
  const minY = Math.min(...ys), maxY = Math.max(...ys);
960
1488
  imagePositions.push({
961
1489
  name,
962
- x: minX * PT_TO_MM2,
963
- y: minY * PT_TO_MM2,
964
- w: (maxX - minX) * PT_TO_MM2,
965
- h: (maxY - minY) * PT_TO_MM2,
1490
+ x: minX * PT_TO_MM3,
1491
+ y: minY * PT_TO_MM3,
1492
+ w: (maxX - minX) * PT_TO_MM3,
1493
+ h: (maxY - minY) * PT_TO_MM3,
966
1494
  inline,
967
1495
  maskFill
968
1496
  });
@@ -978,13 +1506,13 @@ async function walkOps(page, OPS, viewport) {
978
1506
  const br = toViewport(r.x + r.w, r.y);
979
1507
  shapes.push({
980
1508
  kind: "rect",
981
- x: Math.min(tl.x, br.x) * PT_TO_MM2,
982
- y: Math.min(tl.y, br.y) * PT_TO_MM2,
983
- width: Math.abs(br.x - tl.x) * PT_TO_MM2,
984
- height: Math.abs(br.y - tl.y) * PT_TO_MM2,
1509
+ x: Math.min(tl.x, br.x) * PT_TO_MM3,
1510
+ y: Math.min(tl.y, br.y) * PT_TO_MM3,
1511
+ width: Math.abs(br.x - tl.x) * PT_TO_MM3,
1512
+ height: Math.abs(br.y - tl.y) * PT_TO_MM3,
985
1513
  fill: isFill ? gs.fill : void 0,
986
1514
  stroke: isStroke ? gs.stroke : void 0,
987
- strokeWidth: isStroke ? gs.lineWidth * PT_TO_MM2 : void 0,
1515
+ strokeWidth: isStroke ? gs.lineWidth * PT_TO_MM3 : void 0,
988
1516
  opacity: isFill ? gs.fillAlpha : gs.strokeAlpha
989
1517
  });
990
1518
  }
@@ -998,13 +1526,13 @@ async function walkOps(page, OPS, viewport) {
998
1526
  const h = Math.abs(br.y - tl.y);
999
1527
  shapes.push({
1000
1528
  kind: "rect",
1001
- x: x * PT_TO_MM2,
1002
- y: y * PT_TO_MM2,
1003
- width: w * PT_TO_MM2,
1004
- height: h * PT_TO_MM2,
1529
+ x: x * PT_TO_MM3,
1530
+ y: y * PT_TO_MM3,
1531
+ width: w * PT_TO_MM3,
1532
+ height: h * PT_TO_MM3,
1005
1533
  fill: isFill ? gs.fill : void 0,
1006
1534
  stroke: isStroke ? gs.stroke : void 0,
1007
- strokeWidth: isStroke ? gs.lineWidth * PT_TO_MM2 : void 0,
1535
+ strokeWidth: isStroke ? gs.lineWidth * PT_TO_MM3 : void 0,
1008
1536
  opacity: isFill ? gs.fillAlpha : gs.strokeAlpha
1009
1537
  });
1010
1538
  } else if (pathSegments.length === 2 && pathSegments[0].type === "M" && pathSegments[1].type === "L") {
@@ -1016,35 +1544,35 @@ async function walkOps(page, OPS, viewport) {
1016
1544
  const minY = Math.min(va.y, vb.y);
1017
1545
  const maxX = Math.max(va.x, vb.x);
1018
1546
  const maxY = Math.max(va.y, vb.y);
1019
- const x1Local = (va.x - minX) * PT_TO_MM2;
1020
- const y1Local = (va.y - minY) * PT_TO_MM2;
1021
- const x2Local = (vb.x - minX) * PT_TO_MM2;
1022
- const y2Local = (vb.y - minY) * PT_TO_MM2;
1023
- const wLocal = Math.max(0.05, (maxX - minX) * PT_TO_MM2);
1024
- const hLocal = Math.max(0.05, (maxY - minY) * PT_TO_MM2);
1547
+ const x1Local = (va.x - minX) * PT_TO_MM3;
1548
+ const y1Local = (va.y - minY) * PT_TO_MM3;
1549
+ const x2Local = (vb.x - minX) * PT_TO_MM3;
1550
+ const y2Local = (vb.y - minY) * PT_TO_MM3;
1551
+ const wLocal = Math.max(0.05, (maxX - minX) * PT_TO_MM3);
1552
+ const hLocal = Math.max(0.05, (maxY - minY) * PT_TO_MM3);
1025
1553
  const dx = Math.abs(va.x - vb.x);
1026
1554
  const dy = Math.abs(va.y - vb.y);
1027
1555
  const axisAligned = dx < 0.5 || dy < 0.5;
1028
1556
  if (axisAligned) {
1029
1557
  shapes.push({
1030
1558
  kind: "line",
1031
- x: minX * PT_TO_MM2,
1032
- y: minY * PT_TO_MM2,
1559
+ x: minX * PT_TO_MM3,
1560
+ y: minY * PT_TO_MM3,
1033
1561
  width: wLocal,
1034
1562
  height: hLocal,
1035
1563
  stroke: isStroke ? gs.stroke : void 0,
1036
- strokeWidth: isStroke ? gs.lineWidth * PT_TO_MM2 : void 0,
1564
+ strokeWidth: isStroke ? gs.lineWidth * PT_TO_MM3 : void 0,
1037
1565
  opacity: gs.strokeAlpha
1038
1566
  });
1039
1567
  } else {
1040
1568
  shapes.push({
1041
1569
  kind: "path",
1042
- x: minX * PT_TO_MM2,
1043
- y: minY * PT_TO_MM2,
1570
+ x: minX * PT_TO_MM3,
1571
+ y: minY * PT_TO_MM3,
1044
1572
  width: wLocal,
1045
1573
  height: hLocal,
1046
1574
  stroke: isStroke ? gs.stroke : void 0,
1047
- strokeWidth: isStroke ? gs.lineWidth * PT_TO_MM2 : void 0,
1575
+ strokeWidth: isStroke ? gs.lineWidth * PT_TO_MM3 : void 0,
1048
1576
  opacity: gs.strokeAlpha,
1049
1577
  path: `M ${x1Local.toFixed(2)} ${y1Local.toFixed(2)} L ${x2Local.toFixed(2)} ${y2Local.toFixed(2)}`
1050
1578
  });
@@ -1077,20 +1605,20 @@ async function walkOps(page, OPS, viewport) {
1077
1605
  if (seg.type === "Z") return "Z";
1078
1606
  const p = [];
1079
1607
  for (let i = 0; i < seg.pts.length; i += 2) {
1080
- p.push(((seg.pts[i] - minX) * PT_TO_MM2).toFixed(2));
1081
- p.push(((seg.pts[i + 1] - minY) * PT_TO_MM2).toFixed(2));
1608
+ p.push(((seg.pts[i] - minX) * PT_TO_MM3).toFixed(2));
1609
+ p.push(((seg.pts[i + 1] - minY) * PT_TO_MM3).toFixed(2));
1082
1610
  }
1083
1611
  return `${seg.type} ${p.join(" ")}`;
1084
1612
  }).join(" ");
1085
1613
  shapes.push({
1086
1614
  kind: "path",
1087
- x: minX * PT_TO_MM2,
1088
- y: minY * PT_TO_MM2,
1089
- width: bw * PT_TO_MM2,
1090
- height: bh * PT_TO_MM2,
1615
+ x: minX * PT_TO_MM3,
1616
+ y: minY * PT_TO_MM3,
1617
+ width: bw * PT_TO_MM3,
1618
+ height: bh * PT_TO_MM3,
1091
1619
  fill: isFill ? gs.fill : void 0,
1092
1620
  stroke: isStroke ? gs.stroke : void 0,
1093
- strokeWidth: isStroke ? gs.lineWidth * PT_TO_MM2 : void 0,
1621
+ strokeWidth: isStroke ? gs.lineWidth * PT_TO_MM3 : void 0,
1094
1622
  opacity: isFill ? gs.fillAlpha : gs.strokeAlpha,
1095
1623
  path: d
1096
1624
  });
@@ -1431,10 +1959,10 @@ async function extractLinks(doc, page, viewport) {
1431
1959
  const xMax = Math.max(c1.x, c2.x);
1432
1960
  const yMax = Math.max(c1.y, c2.y);
1433
1961
  const rectMm = {
1434
- x: xMin * PT_TO_MM2,
1435
- y: yMin * PT_TO_MM2,
1436
- w: (xMax - xMin) * PT_TO_MM2,
1437
- h: (yMax - yMin) * PT_TO_MM2
1962
+ x: xMin * PT_TO_MM3,
1963
+ y: yMin * PT_TO_MM3,
1964
+ w: (xMax - xMin) * PT_TO_MM3,
1965
+ h: (yMax - yMin) * PT_TO_MM3
1438
1966
  };
1439
1967
  const url = a.url || a.unsafeUrl;
1440
1968
  const destPage = url ? void 0 : await resolveDestPage(doc, a.dest);
@@ -1472,10 +2000,10 @@ async function extractFormWidgets(page, viewport) {
1472
2000
  })).filter((o) => o.value !== "") : [];
1473
2001
  out.push({
1474
2002
  rectMm: {
1475
- x: xMin * PT_TO_MM2,
1476
- y: yMin * PT_TO_MM2,
1477
- w: (xMax - xMin) * PT_TO_MM2,
1478
- h: (yMax - yMin) * PT_TO_MM2
2003
+ x: xMin * PT_TO_MM3,
2004
+ y: yMin * PT_TO_MM3,
2005
+ w: (xMax - xMin) * PT_TO_MM3,
2006
+ h: (yMax - yMin) * PT_TO_MM3
1479
2007
  },
1480
2008
  fieldType: a.fieldType || "",
1481
2009
  fieldName: a.fieldName || `field-${out.length + 1}`,
@@ -1764,12 +2292,12 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
1764
2292
  const w = safeNum(it.width, 0);
1765
2293
  runs.push({
1766
2294
  text: it.str,
1767
- x: safeNum(vx * PT_TO_MM2, 0),
1768
- y: safeNum(yTop * PT_TO_MM2, 0),
2295
+ x: safeNum(vx * PT_TO_MM3, 0),
2296
+ y: safeNum(yTop * PT_TO_MM3, 0),
1769
2297
  fontSize: safeNum(fontSize, 10),
1770
2298
  fontName: it.fontName,
1771
- width: safeNum(w * PT_TO_MM2, 0),
1772
- height: safeNum((it.height || fontSize) * PT_TO_MM2, fontSize * PT_TO_MM2),
2299
+ width: safeNum(w * PT_TO_MM3, 0),
2300
+ height: safeNum((it.height || fontSize) * PT_TO_MM3, fontSize * PT_TO_MM3),
1773
2301
  color: op?.fill || "#000000",
1774
2302
  opacity: invisible ? 0 : safeNum(op?.alpha, 1)
1775
2303
  });
@@ -1783,8 +2311,20 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
1783
2311
  const c = fontMap.get(name) || classifyFont(name || "");
1784
2312
  return `${c.family}|${c.weight || ""}|${c.style || ""}`;
1785
2313
  };
2314
+ const extent = (t) => {
2315
+ if (!/\s$/.test(t.text)) return t.width;
2316
+ const em = t.fontSize * PT_TO_MM3;
2317
+ const est = Math.max(1, t.text.trim().length) * em * kGlyph + em * 0.25;
2318
+ if (stretchedSpaces) return Math.min(t.width, est);
2319
+ return t.width > est * 1.4 ? est : t.width;
2320
+ };
2321
+ const spaceItems = runs.filter((r) => !r.text.trim() && r.text.length && r.width >= r.fontSize * PT_TO_MM3 * 0.5);
2322
+ const wideSpaces = spaceItems.filter((r) => r.width >= r.fontSize * PT_TO_MM3 * 2).length;
2323
+ const tabular = spaceItems.length >= 8 && wideSpaces / spaceItems.length >= 0.25;
2324
+ const gapEm = tabular ? 1 : 2;
1786
2325
  for (const r of runs) {
1787
2326
  if (!r.text.length) continue;
2327
+ if (!r.text.trim() && r.width > r.fontSize * PT_TO_MM3 * gapEm) continue;
1788
2328
  const last = lines[lines.length - 1];
1789
2329
  if (!last) {
1790
2330
  lines.push({ ...r });
@@ -1792,16 +2332,10 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
1792
2332
  }
1793
2333
  const sameLine = Math.abs(last.y - r.y) <= Y_TOL;
1794
2334
  const sameStyle = Math.abs(last.fontSize - r.fontSize) < 0.4 && (last.fontName === r.fontName || fontKey(last.fontName) === fontKey(r.fontName)) && last.color === r.color && Math.abs(last.opacity - r.opacity) < 0.05;
1795
- const emMm = r.fontSize * PT_TO_MM2;
1796
- const extent = (t) => {
1797
- if (!/\s$/.test(t.text)) return t.width;
1798
- const em = t.fontSize * PT_TO_MM2;
1799
- const est = Math.max(1, t.text.trim().length) * em * kGlyph + em * 0.25;
1800
- if (stretchedSpaces) return Math.min(t.width, est);
1801
- return t.width > est * 1.4 ? est : t.width;
1802
- };
2335
+ const emMm = r.fontSize * PT_TO_MM3;
1803
2336
  const gapMm = r.x - (last.x + extent(last));
1804
- const mergeOk = sameLine && sameStyle && gapMm >= -emMm * 0.5 && gapMm <= emMm * 0.45;
2337
+ const symAfterNumber = /^[$€£¥]$/.test(r.text.trim()) && /[\d)]\s*$/.test(last.text);
2338
+ const mergeOk = sameLine && sameStyle && !symAfterNumber && gapMm >= -emMm * 0.5 && gapMm <= emMm * 0.45;
1805
2339
  if (mergeOk) {
1806
2340
  const lastEndsSpace = /\s$/.test(last.text);
1807
2341
  const currStartsSpace = /^\s/.test(r.text);
@@ -1813,12 +2347,36 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
1813
2347
  lines.push({ ...r });
1814
2348
  }
1815
2349
  }
2350
+ {
2351
+ const keep = [];
2352
+ for (const l of lines) {
2353
+ const t = l.text.trim();
2354
+ const recent = keep.slice(-40);
2355
+ const dup = t.length ? recent.find((k) => Math.abs(k.y - l.y) <= 0.6 && Math.abs(k.x - l.x) <= 0.8 && Math.abs(k.fontSize - l.fontSize) < 0.6 && k.text.trim() === t) : void 0;
2356
+ if (dup) {
2357
+ dup.bold = true;
2358
+ continue;
2359
+ }
2360
+ keep.push(l);
2361
+ }
2362
+ lines.length = 0;
2363
+ lines.push(...keep);
2364
+ }
1816
2365
  const elements = [];
2366
+ const lineMeta = /* @__PURE__ */ new WeakMap();
1817
2367
  const tRuns = lines.map((l) => {
1818
2368
  const cls = fontMap.get(l.fontName) || classifyFont(l.fontName || "");
1819
- return { text: l.text, x: l.x, y: l.y, width: l.width, height: l.height, fontSize: l.fontSize, fontName: l.fontName, color: l.color, bold: cls.weight === "bold" };
2369
+ return { text: l.text, x: l.x, y: l.y, width: extent(l), height: l.height, fontSize: l.fontSize, fontName: l.fontName, color: l.color, bold: cls.weight === "bold" || !!l.bold };
1820
2370
  });
1821
- const detected = options.detectTables === false ? [] : detectTables(tRuns, ops.shapes, pageW * PT_TO_MM2);
2371
+ const sizeChars = /* @__PURE__ */ new Map();
2372
+ for (const l of lines) {
2373
+ const k = Math.round(l.fontSize * 2) / 2;
2374
+ sizeChars.set(k, (sizeChars.get(k) ?? 0) + l.text.length);
2375
+ }
2376
+ const bodyFontSize = [...sizeChars.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 0;
2377
+ const gutters = options.readingOrder === false ? [] : detectGutters(lines.map((l) => ({ text: l.text, x: l.x, y: l.y, width: l.width, fontSize: l.fontSize })), bodyFontSize, pageW * PT_TO_MM3);
2378
+ const pageShapes = mergeCollinearShapes(ops.shapes);
2379
+ const detected = options.detectTables === false ? [] : detectTables(tRuns, pageShapes, pageW * PT_TO_MM3, gutters);
1822
2380
  const consumedLines = /* @__PURE__ */ new Set();
1823
2381
  const consumedShapes = /* @__PURE__ */ new Set();
1824
2382
  const tableAtLine = /* @__PURE__ */ new Map();
@@ -1827,8 +2385,8 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
1827
2385
  for (const k of t.shapeIdx) consumedShapes.add(k);
1828
2386
  tableAtLine.set(Math.min(...t.lineIdx), t.element);
1829
2387
  }
1830
- const pageWmm = pageW * PT_TO_MM2, pageHmm = pageH * PT_TO_MM2;
1831
- ops.shapes.forEach((sh, shapeIdx) => {
2388
+ const pageWmm = pageW * PT_TO_MM3, pageHmm = pageH * PT_TO_MM3;
2389
+ pageShapes.forEach((sh, shapeIdx) => {
1832
2390
  if (consumedShapes.has(shapeIdx)) return;
1833
2391
  if (sh.width < 0.3 && sh.height < 0.3) return;
1834
2392
  if (sh.x + sh.width <= 0 || sh.y + sh.height <= 0 || sh.x >= pageWmm || sh.y >= pageHmm) return;
@@ -1871,12 +2429,6 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
1871
2429
  fit: "fill"
1872
2430
  });
1873
2431
  }
1874
- const sizeChars = /* @__PURE__ */ new Map();
1875
- for (const l of lines) {
1876
- const k = Math.round(l.fontSize * 2) / 2;
1877
- sizeChars.set(k, (sizeChars.get(k) ?? 0) + l.text.length);
1878
- }
1879
- const bodyFontSize = [...sizeChars.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 0;
1880
2432
  const rowOf = /* @__PURE__ */ new Map();
1881
2433
  const rowStartOf = /* @__PURE__ */ new Map();
1882
2434
  const nextOnRow = /* @__PURE__ */ new Map();
@@ -1884,10 +2436,10 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
1884
2436
  const order = lines.map((_, i) => i).filter((i) => !consumedLines.has(i));
1885
2437
  for (let a = 0; a < order.length; a++) {
1886
2438
  const i = order[a], li = lines[i];
1887
- const tolY = Math.max(0.6, li.fontSize * PT_TO_MM2 * 0.35);
1888
2439
  let bestNext = -1, bestX = Infinity;
1889
2440
  for (let b = 0; b < order.length; b++) {
1890
2441
  const j = order[b], lj = lines[j];
2442
+ const tolY = Math.max(0.6, Math.max(li.fontSize, lj.fontSize) * PT_TO_MM3 * 0.5);
1891
2443
  if (j === i || Math.abs(lj.y - li.y) > tolY || lj.x <= li.x) continue;
1892
2444
  if (lj.x < bestX) {
1893
2445
  bestX = lj.x;
@@ -1904,8 +2456,8 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
1904
2456
  let cur = i;
1905
2457
  while (nextOnRow.has(cur)) {
1906
2458
  const j = nextOnRow.get(cur), lc = lines[cur], lj = lines[j];
1907
- const em = Math.min(lc.fontSize, lj.fontSize) * PT_TO_MM2;
1908
- const gap = lj.x - (lc.x + lc.width);
2459
+ const em = Math.min(lc.fontSize, lj.fontSize) * PT_TO_MM3;
2460
+ const gap = lj.x - (lc.x + extent(lc));
1909
2461
  if (gap < -em * 0.3 || gap > em * 0.6) break;
1910
2462
  row.push(j);
1911
2463
  seen.add(j);
@@ -1917,7 +2469,7 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
1917
2469
  }
1918
2470
  const runStyle = (l) => {
1919
2471
  const cls = fontMap.get(l.fontName) || classifyFont(l.fontName || "");
1920
- return { cls, bold: cls.weight === "bold", italic: cls.style === "italic" };
2472
+ return { cls, bold: cls.weight === "bold" || !!l.bold, italic: cls.style === "italic" };
1921
2473
  };
1922
2474
  lines.forEach((l, lineIdx) => {
1923
2475
  const tableEl = tableAtLine.get(lineIdx);
@@ -1928,10 +2480,10 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
1928
2480
  if (row.length > 1) {
1929
2481
  const first = lines[row[0]], last = lines[row[row.length - 1]];
1930
2482
  const base = runStyle(first);
1931
- const rowEnd = last.x + last.width;
1932
- const measuredW = Math.max((rowEnd - first.x) * 1.2 + first.fontSize * PT_TO_MM2 * 0.4, first.fontSize * PT_TO_MM2);
2483
+ const rowEnd = last.x + extent(last);
2484
+ const measuredW = Math.max((rowEnd - first.x) * 1.2 + first.fontSize * PT_TO_MM3 * 0.4, first.fontSize * PT_TO_MM3);
1933
2485
  const nextIdx2 = nextOnRow.get(row[row.length - 1]);
1934
- const cap2 = nextIdx2 != null ? lines[nextIdx2].x - first.x - first.fontSize * PT_TO_MM2 * 0.3 : pageWmm - first.x;
2486
+ const cap2 = nextIdx2 != null ? lines[nextIdx2].x - first.x - first.fontSize * PT_TO_MM3 * 0.3 : pageWmm - first.x;
1935
2487
  const runs2 = [];
1936
2488
  row.forEach((idx, k) => {
1937
2489
  const r = lines[idx];
@@ -1939,8 +2491,8 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
1939
2491
  let text2 = r.text;
1940
2492
  if (k > 0) {
1941
2493
  const prev2 = lines[row[k - 1]];
1942
- const gap = r.x - (prev2.x + prev2.width);
1943
- if (gap > r.fontSize * PT_TO_MM2 * 0.08 && !/\s$/.test(prev2.text) && !/^\s/.test(text2)) text2 = " " + text2;
2494
+ const gap = r.x - (prev2.x + extent(prev2));
2495
+ if (gap > r.fontSize * PT_TO_MM3 * 0.08 && !/\s$/.test(prev2.text) && !/^\s/.test(text2)) text2 = " " + text2;
1944
2496
  }
1945
2497
  const run = { text: text2 };
1946
2498
  if (st.bold) run.bold = true;
@@ -1954,13 +2506,16 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
1954
2506
  });
1955
2507
  const style2 = { fontSize: Math.round(first.fontSize * 10) / 10, fontFamily: base.cls.family };
1956
2508
  if (first.opacity < 0.999) style2.opacity = Math.round(first.opacity * 100) / 100;
1957
- elements.push({
2509
+ const rt = {
1958
2510
  type: "richtext",
1959
2511
  runs: runs2,
1960
2512
  position: { x: Math.max(0, Math.round(first.x * 100) / 100), y: Math.max(0, Math.round(Math.min(...row.map((i) => lines[i].y)) * 100) / 100) },
1961
- width: Math.max(2, Math.round(Math.max(first.fontSize * PT_TO_MM2, Math.min(measuredW, cap2)) * 100) / 100),
2513
+ width: Math.max(2, Math.round(Math.max(first.fontSize * PT_TO_MM3, Math.min(measuredW, cap2)) * 100) / 100),
1962
2514
  style: style2
1963
- });
2515
+ };
2516
+ const dominant = row.map((i) => lines[i]).sort((a, b) => b.text.trim().length - a.text.trim().length)[0];
2517
+ lineMeta.set(rt, { w: Math.max(first.fontSize * PT_TO_MM3, rowEnd - first.x), size: dominant.fontSize, face: fontKey(dominant.fontName) });
2518
+ elements.push(rt);
1964
2519
  return;
1965
2520
  }
1966
2521
  const cls = fontMap.get(l.fontName) || classifyFont(l.fontName || "");
@@ -1968,15 +2523,15 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
1968
2523
  fontSize: Math.round(l.fontSize * 10) / 10,
1969
2524
  fontFamily: cls.family
1970
2525
  };
1971
- if (cls.weight === "bold") style.fontWeight = "bold";
2526
+ if (cls.weight === "bold" || l.bold) style.fontWeight = "bold";
1972
2527
  if (cls.style === "italic") style.fontStyle = "italic";
1973
2528
  if (l.color !== "#000000") style.color = l.color;
1974
2529
  if (l.opacity < 0.999) style.opacity = Math.round(l.opacity * 100) / 100;
1975
2530
  const link = findLinkForRun2(l);
1976
- const measured = Math.max(l.width * 1.2 + l.fontSize * PT_TO_MM2 * 0.4, l.fontSize * PT_TO_MM2);
2531
+ const measured = Math.max(extent(l) * 1.2 + l.fontSize * PT_TO_MM3 * 0.4, l.fontSize * PT_TO_MM3);
1977
2532
  const remaining = Math.max(measured, pageWmm - l.x);
1978
2533
  const nextIdx = nextOnRow.get(lineIdx);
1979
- const cap = nextIdx != null ? Math.max(l.fontSize * PT_TO_MM2, lines[nextIdx].x - l.x - l.fontSize * PT_TO_MM2 * 0.3) : Infinity;
2534
+ const cap = nextIdx != null ? Math.max(l.fontSize * PT_TO_MM3, lines[nextIdx].x - l.x - l.fontSize * PT_TO_MM3 * 0.3) : Infinity;
1980
2535
  const elWidth = Math.min(measured, remaining, cap);
1981
2536
  const text = {
1982
2537
  type: "text",
@@ -1985,7 +2540,7 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
1985
2540
  width: Math.max(2, Math.round(elWidth * 100) / 100),
1986
2541
  style
1987
2542
  };
1988
- if (cls.weight === "bold" && l.text.trim().length <= 120 && !consumedLines.has(lineIdx)) {
2543
+ if ((cls.weight === "bold" || l.bold) && l.text.trim().length <= 120 && !consumedLines.has(lineIdx)) {
1989
2544
  const ratio = bodyFontSize > 0 ? l.fontSize / bodyFontSize : 1;
1990
2545
  if (l.fontSize >= 22 || ratio >= 1.8) text.heading = 1;
1991
2546
  else if (l.fontSize >= 17 || ratio >= 1.35) text.heading = 2;
@@ -1997,14 +2552,26 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
1997
2552
  else if (link.destPage != null) text.link = { type: "internal", target: `#page-${link.destPage + 1}` };
1998
2553
  }
1999
2554
  const prev = elements[elements.length - 1];
2000
- if (text.heading && prev && prev.type === "text" && prev.heading === text.heading && !link && !prev.link && Math.abs(prev.style?.fontSize - style.fontSize) < 0.5 && Math.abs(prev.position.x - text.position.x) < 1 && text.position.y - prev.position.y < l.fontSize * PT_TO_MM2 * 2.2 && text.position.y > prev.position.y) {
2555
+ if (text.heading && prev && prev.type === "text" && prev.heading === text.heading && !link && !prev.link && Math.abs(prev.style?.fontSize - style.fontSize) < 0.5 && Math.abs(prev.position.x - text.position.x) < 1 && text.position.y - prev.position.y < l.fontSize * PT_TO_MM3 * 2.2 && text.position.y > prev.position.y) {
2001
2556
  prev.content = `${prev.content} ${text.content}`.replace(/\s+/g, " ");
2002
2557
  prev.tocEntry = prev.content;
2003
2558
  prev.width = Math.max(prev.width ?? 0, text.width ?? 0);
2004
2559
  return;
2005
2560
  }
2561
+ lineMeta.set(text, { w: Math.max(l.fontSize * PT_TO_MM3, extent(l)), size: l.fontSize, face: fontKey(l.fontName) });
2006
2562
  elements.push(text);
2007
2563
  });
2564
+ if (options.readingOrder !== false) {
2565
+ if (gutters.length) {
2566
+ const isFlow = (e) => e.type === "text" || e.type === "richtext" || e.type === "table";
2567
+ const flow = elements.filter(isFlow), rest = elements.filter((e) => !isFlow(e));
2568
+ elements.splice(0, elements.length, ...rest, ...orderByColumns(flow, gutters, pageWmm));
2569
+ }
2570
+ }
2571
+ if (options.foldParagraphs !== false) {
2572
+ const folded = foldParagraphs(elements, lineMeta, pageWmm);
2573
+ elements.splice(0, elements.length, ...folded);
2574
+ }
2008
2575
  for (const w of formWidgets) {
2009
2576
  if (w.pushButton) continue;
2010
2577
  const baseEl = {
@@ -2039,7 +2606,7 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
2039
2606
  }
2040
2607
  pages.push({
2041
2608
  id: `page-${pi}`,
2042
- pageSize: { width: Math.round(pageW * PT_TO_MM2 * 100) / 100, height: Math.round(pageH * PT_TO_MM2 * 100) / 100 },
2609
+ pageSize: { width: Math.round(pageW * PT_TO_MM3 * 100) / 100, height: Math.round(pageH * PT_TO_MM3 * 100) / 100 },
2043
2610
  margins: { top: 0, right: 0, bottom: 0, left: 0 },
2044
2611
  elements
2045
2612
  });
@@ -2599,10 +3166,11 @@ function serializeElement(el) {
2599
3166
  (row) => row.map((c, i) => {
2600
3167
  const h = headers[i];
2601
3168
  const v = cell(c).trim();
3169
+ if (!v) return "";
2602
3170
  return h ? `${h}: ${v}` : v;
2603
3171
  }).filter((s) => s !== "").join(" | ")
2604
3172
  );
2605
- return lines.join("\n");
3173
+ return lines.filter((l) => l !== "").join("\n");
2606
3174
  }
2607
3175
  case "collapsible": {
2608
3176
  const title = String(e.title ?? "").trim();
@@ -103,6 +103,7 @@
103
103
  "backgroundColor": { "type": "string" },
104
104
  "textAlign": { "type": "string", "enum": ["left","center","right","justify"] },
105
105
  "lineHeight": { "type": "number" },
106
+ "textIndent": { "type": "number", "description": "First-line indent in the document unit." },
106
107
  "letterSpacing": { "oneOf": [{ "type": "number" }, { "type": "string" }] },
107
108
  "padding": { "oneOf": [{ "type": "number" }, { "type": "string" }, { "$ref": "#/definitions/Margins" }] },
108
109
  "margin": { "oneOf": [{ "type": "number" }, { "type": "string" }, { "$ref": "#/definitions/Margins" }] },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uurtech/jdf-cli",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Command-line tool for the JDF (JSON Document Format) — validate and convert documents.",
5
5
  "license": "MIT",
6
6
  "author": "Ugur Kazdal",