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() {