From 1c6f619362df77b224c3def24ce1f2f851c57a7f Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Mon, 2 Nov 2026 03:07:42 +0900 Subject: [PATCH] PDF to Word: keep a ruled table whose text runs past a cell A spreadsheet printed to PDF cuts long text off at its cell, and the PDF only clips the rest, so the hidden part still reads as part of the word. That word then sat across the cell's right rule, and the table finder took any rule through a word as a sign of a chart, dropping the whole 40-row measurement table from #138 to plain text. A word that runs on past a vertical rule now stays in the cell it starts in, for up to one in twenty words of the grid (at least one). A rule through a word's height (a strike-through) still means no table. Co-Authored-By: Claude Opus 5.5 --- .../Pdf/PdfDocumentReader.Tables.cs | 21 +++++++--- tests/Filee.Engines.Tests/PdfTableTests.cs | 38 +++++++++++++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/Filee.Engines/Pdf/PdfDocumentReader.Tables.cs b/src/Filee.Engines/Pdf/PdfDocumentReader.Tables.cs index 5241844d0..cddaafcad 100644 --- a/src/Filee.Engines/Pdf/PdfDocumentReader.Tables.cs +++ b/src/Filee.Engines/Pdf/PdfDocumentReader.Tables.cs @@ -7,7 +7,8 @@ // page (lines, paragraphs, alignment within the cell), and cell borders, shading and row heights come from the PDF. // // Conservative on purpose: a grid needs 2 rows and 2 columns, merged cells must be rectangles, text must sit in the -// cells (a rule through a word means a chart or a strike-through, not a table) and at least two cells hold text. +// cells (a rule through a word means a chart or a strike-through, not a table; only a few words may run on past +// their cell's right rule, as long spreadsheet text does) and at least two cells hold text. // Anything else stays text in reading order as before. Tables without rules (aligned by white space only) are not // detected; a table running over several pages becomes one table per page. @@ -373,17 +374,27 @@ private static List Edges(IEnumerable positions) if (cells.Count < 4 && (cells.Select(c => c.Row).Distinct().Count() < 2 || cells.Select(c => c.Column).Distinct().Count() < 2)) return null; - // Words by their centre; a rule through the middle of a word means this is no table around text. + // Words by their centre; a rule through the middle of a word means this is no table around text. Except a word + // that runs on past its cell's right rule: a spreadsheet cuts long text off at the cell (the PDF only clips the + // rest) or lets it run into the empty cell next to it. A few of those stay in the cell they start in. var bounds = new PdfRectangle(xs[0], ys[^1], xs[^1], ys[0]); + bool Inside(double x, double y) => x > bounds.Left && x < bounds.Right && y > bounds.Bottom && y < bounds.Top; + var overflows = Math.Max(1, words.Count(w => Inside((w.Left + w.Right) / 2, (w.Top + w.Bottom) / 2)) / 20); foreach (var word in words) { var (x, y) = ((word.Left + word.Right) / 2, (word.Top + word.Bottom) / 2); - if (x <= bounds.Left || x >= bounds.Right || y <= bounds.Bottom || y >= bounds.Top) + if (!Inside(x, y)) continue; var band = (word.Top - word.Bottom) * 0.3; - if (vertical.Any(v => v.Position > word.Left + 1 && v.Position < word.Right - 1 && v.Start < y && v.End > y) - || horizontal.Any(h => h.Position > word.Bottom + band && h.Position < word.Top - band && h.Start < word.Right - 1 && h.End > word.Left + 1)) + if (horizontal.Any(h => h.Position > word.Bottom + band && h.Position < word.Top - band && h.Start < word.Right - 1 && h.End > word.Left + 1)) return null; + var through = vertical.Where(v => v.Position > word.Left + 1 && v.Position < word.Right - 1 && v.Start < y && v.End > y).ToList(); + if (through.Count > 0) + { + if (--overflows < 0) + return null; + x = (word.Left + through.Min(v => v.Position)) / 2; + } var column = Math.Clamp(xs.FindLastIndex(e => e <= x), 0, columns - 1); var row = Math.Clamp(ys.FindLastIndex(e => e >= y), 0, rows - 1); cells[owner[row, column]].Words.Add(word); diff --git a/tests/Filee.Engines.Tests/PdfTableTests.cs b/tests/Filee.Engines.Tests/PdfTableTests.cs index 8930152a4..ead6295b6 100644 --- a/tests/Filee.Engines.Tests/PdfTableTests.cs +++ b/tests/Filee.Engines.Tests/PdfTableTests.cs @@ -163,6 +163,44 @@ public async Task Rules_drawn_as_bars_keep_their_weight_and_shading() Assert.Null(inner.Fill); } + [Fact] + public async Task Long_text_running_past_its_cell_stays_in_that_cell() + { + // A spreadsheet's measurement sheet (#138): one label is longer than its column, so its last word runs on past + // the cell's right rule, where the spreadsheet cut it off (the PDF only clips it). + var dir = fx.NewFolder(); + var pdf = new PdfBuilder().NewPage(); + double[] xs = [50, 200, 260, 400]; + double[] ys = [100, 120, 140, 160, 180]; + foreach (var y in ys) + pdf.Rule(xs[0], y, xs[^1], y); + foreach (var x in xs) + pdf.Rule(x, ys[0], x, ys[^1]); + string[][] cells = + [ + ["Point of measurement", "Tol.", "M"], + ["Shoulder seam forward", "1/8", "1/2"], + ["Front neck drop at the collar notch", "1/8", "3 1/2"], + ["Collar point", "1/8", "3.5"], + ]; + for (var r = 0; r < 4; r++) + { + for (var c = 0; c < 3; c++) + Cell(pdf, cells[r][c], c == 1 ? xs[c] + 16 : xs[c], ys[r], bold: r == 0); // tolerances sit mid-column + } + var label = pdf.Width(cells[2][0], 10, pdf.Font()); + Assert.InRange(xs[0] + 4 + label, xs[1] + 2, xs[1] + 16); // its last word straddles the rule, short of the next text + var docx = await ConvertAsync(pdf.Save(Path.Combine(dir, "overflow.pdf")), "docx"); + + ValidPackage(docx); + var table = Assert.Single(ReadBack(docx).Sections.SelectMany(s => s.Blocks).OfType()); + var texts = Texts(table); + Assert.Equal(4, texts.Length); + Assert.Equal("Front neck drop at the collar notch", texts[2][0]); + Assert.Equal(["1/8", "3 1/2"], texts[2][1..]); + Assert.Equal(cells[3], texts[3]); + } + [Fact] public async Task Boxes_underlines_and_empty_grids_stay_text() {