@uurtech/jdf-cli 0.2.3 → 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.
Files changed (2) hide show
  1. package/dist/index.js +483 -97
  2. package/package.json +1 -1
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,180 +685,494 @@ 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;
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);
710
797
  }
711
798
  }
712
- return bands.map(({ x0, x1 }) => ({ x0, x1 }));
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++;
807
+ }
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(/[%)]$/, ""));
822
+ var dbg = (...a) => {
823
+ if (typeof process !== "undefined" && process.env?.JDF_TABLE_DEBUG) console.error("[tables]", ...a);
824
+ };
715
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
918
  if (gutters.length && !hasLattice) {
765
- const b = best.bands;
766
- const straddles = b.some((band, k) => k < b.length - 1 && gutters.some((g) => band.x1 <= g.x1 + 1 && b[k + 1].x0 >= g.x0 - 1) && band.x1 - band.x0 > 30 && b[k + 1].x1 - b[k + 1].x0 > 30);
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));
767
921
  if (straddles) {
922
+ dbg("reject: straddles a column gutter");
768
923
  i++;
769
924
  continue;
770
925
  }
771
926
  }
772
- const bands = best.bands;
773
- 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();
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 ?? []];
774
974
  const grid = [];
975
+ const gridRows = [];
775
976
  const lineIdx = [];
776
- for (const row of blockRows) {
777
- 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) {
778
993
  const c = row.cells[0];
779
- 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));
780
995
  if (b > 0) {
996
+ flushPending();
781
997
  grid[grid.length - 1][b] = (grid[grid.length - 1][b] + " " + c.run.text.trim()).trim();
782
- 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));
783
1010
  continue;
784
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);
785
1024
  }
786
- grid.push(bands.map((_, b) => cellText(row, b)));
787
- for (const c of row.cells) {
788
- lineIdx.push(c.idx);
789
- 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;
790
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();
791
1050
  }
792
1051
  if (lineIdx.some((k) => used.has(k))) {
793
1052
  i = best.j + 1;
794
1053
  continue;
795
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
+ }
796
1069
  const first = blockRows[0];
797
- const tableW = bbox.x1 - bbox.x0;
798
- const rowFill = (row) => {
799
- const cy = row.y + row.h * 0.5;
800
- 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);
801
- const covered = rects.reduce((a, { s }) => a + s.width, 0);
802
- if (!rects.length || covered < tableW * 0.5) return null;
803
- const counts = /* @__PURE__ */ new Map();
804
- for (const { s } of rects) counts.set(s.fill, (counts.get(s.fill) ?? 0) + s.width);
805
- const fill = [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0];
806
- return { fill, rects };
807
- };
808
1070
  const headerBg = rowFill(first);
809
1071
  const firstBold = first.cells.every((c) => c.run.bold);
810
1072
  const bodyFills = blockRows.slice(1).map(rowFill);
811
1073
  const headerDistinct = !!headerBg && !bodyFills.every((f) => f?.fill === headerBg.fill);
812
- 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;
813
1102
  const columns = bands.map((b, k) => {
814
- const vals = grid.slice(isHeader ? 1 : 0).map((r) => r[k]).filter(Boolean);
815
- const numericShare = vals.length ? vals.filter(numeric).length / vals.length : 0;
816
1103
  const col = { width: Math.round((b.x1 - b.x0) * 10) / 10 };
817
- if (numericShare >= 0.7) col.align = "right";
1104
+ if (isNumCol[k]) col.align = "right";
818
1105
  return col;
819
1106
  });
820
1107
  const x0 = Math.max(0, bands[0].x0 - 2.5);
821
1108
  const x1 = Math.min(pageW, bands[bands.length - 1].x1 + 2.5);
822
- for (let k = 0; k < bands.length; k++) {
823
- const left = k === 0 ? x0 : (bands[k - 1].x1 + bands[k].x0) / 2;
824
- const right = k === bands.length - 1 ? x1 : (bands[k].x1 + bands[k + 1].x0) / 2;
825
- columns[k].width = Math.round((right - left) * 10) / 10;
826
- }
827
- 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);
828
1115
  const odd = rowFills.filter((_, k) => k % 2 === 1), even = rowFills.filter((_, k) => k % 2 === 0);
829
- const altColor = odd.length && odd[0] && odd.every((f) => f === odd[0]) && even.every((f) => f !== odd[0]) ? odd[0] : void 0;
830
- 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));
831
1120
  const borderColor = borderShape ? borderShape.s.stroke || borderShape.s.fill : void 0;
832
- const fontSize = Math.round(first.cells[0].run.fontSize * 10) / 10;
833
- 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;
834
1132
  const element = {
835
1133
  type: "table",
836
1134
  position: { x: Math.round(x0 * 100) / 100, y: Math.round(Math.max(0, y0) * 100) / 100 },
837
1135
  width: Math.round((x1 - x0) * 100) / 100,
838
1136
  columns,
839
- rows: isHeader ? grid.slice(1) : grid,
840
- 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` }
841
1143
  };
842
- if (isHeader) {
843
- 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());
844
1146
  const hs = { fontWeight: "bold" };
845
1147
  if (headerBg) hs.backgroundColor = headerBg.fill;
846
- const hc = first.cells[0].run.color;
847
- 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
+ }
848
1154
  element.headerStyle = hs;
849
1155
  }
850
- if (altColor) element.alternatingRowColor = altColor;
851
- 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]);
852
1174
  for (const k of lineIdx) used.add(k);
853
- out.push({ element, lineIdx, shapeIdx: gridShapes.map(({ k }) => k) });
1175
+ out.push({ element, lineIdx, shapeIdx: consumedShapes });
854
1176
  i = best.j + 1;
855
1177
  }
856
1178
  return out;
@@ -1040,6 +1362,47 @@ function rgbToHex(r, g, b) {
1040
1362
  const h = (n) => clampByte(n).toString(16).padStart(2, "0");
1041
1363
  return `#${h(r)}${h(g)}${h(b)}`;
1042
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
+ }
1043
1406
  function averageStops(stops) {
1044
1407
  if (!Array.isArray(stops) || stops.length === 0) return null;
1045
1408
  let r = 0, g = 0, b = 0, n = 0;
@@ -1948,8 +2311,20 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
1948
2311
  const c = fontMap.get(name) || classifyFont(name || "");
1949
2312
  return `${c.family}|${c.weight || ""}|${c.style || ""}`;
1950
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;
1951
2325
  for (const r of runs) {
1952
2326
  if (!r.text.length) continue;
2327
+ if (!r.text.trim() && r.width > r.fontSize * PT_TO_MM3 * gapEm) continue;
1953
2328
  const last = lines[lines.length - 1];
1954
2329
  if (!last) {
1955
2330
  lines.push({ ...r });
@@ -1958,15 +2333,9 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
1958
2333
  const sameLine = Math.abs(last.y - r.y) <= Y_TOL;
1959
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;
1960
2335
  const emMm = r.fontSize * PT_TO_MM3;
1961
- const extent = (t) => {
1962
- if (!/\s$/.test(t.text)) return t.width;
1963
- const em = t.fontSize * PT_TO_MM3;
1964
- const est = Math.max(1, t.text.trim().length) * em * kGlyph + em * 0.25;
1965
- if (stretchedSpaces) return Math.min(t.width, est);
1966
- return t.width > est * 1.4 ? est : t.width;
1967
- };
1968
2336
  const gapMm = r.x - (last.x + extent(last));
1969
- 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;
1970
2339
  if (mergeOk) {
1971
2340
  const lastEndsSpace = /\s$/.test(last.text);
1972
2341
  const currStartsSpace = /^\s/.test(r.text);
@@ -1978,11 +2347,26 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
1978
2347
  lines.push({ ...r });
1979
2348
  }
1980
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
+ }
1981
2365
  const elements = [];
1982
2366
  const lineMeta = /* @__PURE__ */ new WeakMap();
1983
2367
  const tRuns = lines.map((l) => {
1984
2368
  const cls = fontMap.get(l.fontName) || classifyFont(l.fontName || "");
1985
- 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 };
1986
2370
  });
1987
2371
  const sizeChars = /* @__PURE__ */ new Map();
1988
2372
  for (const l of lines) {
@@ -1991,7 +2375,8 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
1991
2375
  }
1992
2376
  const bodyFontSize = [...sizeChars.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 0;
1993
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);
1994
- const detected = options.detectTables === false ? [] : detectTables(tRuns, ops.shapes, pageW * PT_TO_MM3, gutters);
2378
+ const pageShapes = mergeCollinearShapes(ops.shapes);
2379
+ const detected = options.detectTables === false ? [] : detectTables(tRuns, pageShapes, pageW * PT_TO_MM3, gutters);
1995
2380
  const consumedLines = /* @__PURE__ */ new Set();
1996
2381
  const consumedShapes = /* @__PURE__ */ new Set();
1997
2382
  const tableAtLine = /* @__PURE__ */ new Map();
@@ -2001,7 +2386,7 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
2001
2386
  tableAtLine.set(Math.min(...t.lineIdx), t.element);
2002
2387
  }
2003
2388
  const pageWmm = pageW * PT_TO_MM3, pageHmm = pageH * PT_TO_MM3;
2004
- ops.shapes.forEach((sh, shapeIdx) => {
2389
+ pageShapes.forEach((sh, shapeIdx) => {
2005
2390
  if (consumedShapes.has(shapeIdx)) return;
2006
2391
  if (sh.width < 0.3 && sh.height < 0.3) return;
2007
2392
  if (sh.x + sh.width <= 0 || sh.y + sh.height <= 0 || sh.x >= pageWmm || sh.y >= pageHmm) return;
@@ -2072,7 +2457,7 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
2072
2457
  while (nextOnRow.has(cur)) {
2073
2458
  const j = nextOnRow.get(cur), lc = lines[cur], lj = lines[j];
2074
2459
  const em = Math.min(lc.fontSize, lj.fontSize) * PT_TO_MM3;
2075
- const gap = lj.x - (lc.x + lc.width);
2460
+ const gap = lj.x - (lc.x + extent(lc));
2076
2461
  if (gap < -em * 0.3 || gap > em * 0.6) break;
2077
2462
  row.push(j);
2078
2463
  seen.add(j);
@@ -2084,7 +2469,7 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
2084
2469
  }
2085
2470
  const runStyle = (l) => {
2086
2471
  const cls = fontMap.get(l.fontName) || classifyFont(l.fontName || "");
2087
- return { cls, bold: cls.weight === "bold", italic: cls.style === "italic" };
2472
+ return { cls, bold: cls.weight === "bold" || !!l.bold, italic: cls.style === "italic" };
2088
2473
  };
2089
2474
  lines.forEach((l, lineIdx) => {
2090
2475
  const tableEl = tableAtLine.get(lineIdx);
@@ -2095,7 +2480,7 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
2095
2480
  if (row.length > 1) {
2096
2481
  const first = lines[row[0]], last = lines[row[row.length - 1]];
2097
2482
  const base = runStyle(first);
2098
- const rowEnd = last.x + last.width;
2483
+ const rowEnd = last.x + extent(last);
2099
2484
  const measuredW = Math.max((rowEnd - first.x) * 1.2 + first.fontSize * PT_TO_MM3 * 0.4, first.fontSize * PT_TO_MM3);
2100
2485
  const nextIdx2 = nextOnRow.get(row[row.length - 1]);
2101
2486
  const cap2 = nextIdx2 != null ? lines[nextIdx2].x - first.x - first.fontSize * PT_TO_MM3 * 0.3 : pageWmm - first.x;
@@ -2106,7 +2491,7 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
2106
2491
  let text2 = r.text;
2107
2492
  if (k > 0) {
2108
2493
  const prev2 = lines[row[k - 1]];
2109
- const gap = r.x - (prev2.x + prev2.width);
2494
+ const gap = r.x - (prev2.x + extent(prev2));
2110
2495
  if (gap > r.fontSize * PT_TO_MM3 * 0.08 && !/\s$/.test(prev2.text) && !/^\s/.test(text2)) text2 = " " + text2;
2111
2496
  }
2112
2497
  const run = { text: text2 };
@@ -2138,12 +2523,12 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
2138
2523
  fontSize: Math.round(l.fontSize * 10) / 10,
2139
2524
  fontFamily: cls.family
2140
2525
  };
2141
- if (cls.weight === "bold") style.fontWeight = "bold";
2526
+ if (cls.weight === "bold" || l.bold) style.fontWeight = "bold";
2142
2527
  if (cls.style === "italic") style.fontStyle = "italic";
2143
2528
  if (l.color !== "#000000") style.color = l.color;
2144
2529
  if (l.opacity < 0.999) style.opacity = Math.round(l.opacity * 100) / 100;
2145
2530
  const link = findLinkForRun2(l);
2146
- const measured = Math.max(l.width * 1.2 + l.fontSize * PT_TO_MM3 * 0.4, l.fontSize * PT_TO_MM3);
2531
+ const measured = Math.max(extent(l) * 1.2 + l.fontSize * PT_TO_MM3 * 0.4, l.fontSize * PT_TO_MM3);
2147
2532
  const remaining = Math.max(measured, pageWmm - l.x);
2148
2533
  const nextIdx = nextOnRow.get(lineIdx);
2149
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;
@@ -2155,7 +2540,7 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
2155
2540
  width: Math.max(2, Math.round(elWidth * 100) / 100),
2156
2541
  style
2157
2542
  };
2158
- 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)) {
2159
2544
  const ratio = bodyFontSize > 0 ? l.fontSize / bodyFontSize : 1;
2160
2545
  if (l.fontSize >= 22 || ratio >= 1.8) text.heading = 1;
2161
2546
  else if (l.fontSize >= 17 || ratio >= 1.35) text.heading = 2;
@@ -2173,7 +2558,7 @@ async function importPdfToJdf(source, title, runtime, options = {}) {
2173
2558
  prev.width = Math.max(prev.width ?? 0, text.width ?? 0);
2174
2559
  return;
2175
2560
  }
2176
- lineMeta.set(text, { w: Math.max(l.fontSize * PT_TO_MM3, l.width), size: l.fontSize, face: fontKey(l.fontName) });
2561
+ lineMeta.set(text, { w: Math.max(l.fontSize * PT_TO_MM3, extent(l)), size: l.fontSize, face: fontKey(l.fontName) });
2177
2562
  elements.push(text);
2178
2563
  });
2179
2564
  if (options.readingOrder !== false) {
@@ -2781,10 +3166,11 @@ function serializeElement(el) {
2781
3166
  (row) => row.map((c, i) => {
2782
3167
  const h = headers[i];
2783
3168
  const v = cell(c).trim();
3169
+ if (!v) return "";
2784
3170
  return h ? `${h}: ${v}` : v;
2785
3171
  }).filter((s) => s !== "").join(" | ")
2786
3172
  );
2787
- return lines.join("\n");
3173
+ return lines.filter((l) => l !== "").join("\n");
2788
3174
  }
2789
3175
  case "collapsible": {
2790
3176
  const title = String(e.title ?? "").trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uurtech/jdf-cli",
3
- "version": "0.2.3",
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",