vendor: import KillerPDF 1.7.5 source (GPL-3.0) as the base for MMD PDF
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
using System.IO;
|
||||
using UglyToad.PdfPig;
|
||||
|
||||
namespace KillerPDF.Services
|
||||
{
|
||||
/// <summary>One selectable character on a page, in reading order. Coordinates are PDF space
|
||||
/// (points, bottom-left origin), matching SearchService and ExtractTextFromRegion.</summary>
|
||||
internal readonly struct RunChar
|
||||
{
|
||||
public readonly string Value; // PdfPig letters can be multi-char (ligatures)
|
||||
public readonly double Left;
|
||||
public readonly double Right;
|
||||
public readonly int Word; // ordinal of the word this char belongs to (for word counts / spacing)
|
||||
public readonly int Line; // ordinal of the line this char belongs to
|
||||
|
||||
public RunChar(string value, double left, double right, int word, int line)
|
||||
{ Value = value; Left = left; Right = right; Word = word; Line = line; }
|
||||
}
|
||||
|
||||
/// <summary>A visual line of text: a contiguous slice of the page's flattened char list plus its
|
||||
/// vertical band. Caret positions run 0..N over the flattened chars; a line's End caret is the
|
||||
/// next line's Start, so a selection ending at End stops cleanly at the line break.</summary>
|
||||
internal sealed class RunLine
|
||||
{
|
||||
public int Start; // caret index of the line's first char
|
||||
public int Count;
|
||||
public double Top; // PDF space: Top > Bottom
|
||||
public double Bottom;
|
||||
public double Left;
|
||||
public double Right;
|
||||
public bool RightToLeft;
|
||||
public int End => Start + Count;
|
||||
}
|
||||
|
||||
/// <summary>Reading-order text geometry for one page.</summary>
|
||||
internal sealed class PageTextRuns
|
||||
{
|
||||
public double PdfWidth;
|
||||
public double PdfHeight;
|
||||
public List<RunChar> Chars = [];
|
||||
public List<RunLine> Lines = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds and caches per-page reading-order character runs for flowing text selection (#127).
|
||||
/// Word geometry comes from PdfPig's GetWords - the same source SearchService and the region
|
||||
/// text extractor use - so selection quads land exactly where search highlights do. Words are
|
||||
/// grouped into lines by vertical overlap and ordered top-to-bottom, then in each line's
|
||||
/// detected reading direction.
|
||||
/// Known shared limitation: like the search highlights, boxes ignore in-memory page rotation.
|
||||
/// Column note: line grouping is by vertical band, so side-by-side columns join into one line;
|
||||
/// good enough for v1, revisit with a segmenter if multi-column PDFs bite.
|
||||
/// </summary>
|
||||
internal sealed class TextRunService
|
||||
{
|
||||
// Keyed by (path, last-write ticks, page): a resave or temp-reload changes the key, so stale
|
||||
// geometry can never serve a newer file. Nulls are cached too - a file PdfPig cannot open
|
||||
// should not be re-parsed on every click.
|
||||
private readonly Dictionary<(string Path, long Ticks, int Page), PageTextRuns?> _cache = [];
|
||||
|
||||
public PageTextRuns? GetPage(string path, int pageIdx)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path) || pageIdx < 0) return null;
|
||||
long ticks;
|
||||
try { ticks = File.GetLastWriteTimeUtc(path).Ticks; }
|
||||
catch { return null; }
|
||||
|
||||
var key = (path, ticks, pageIdx);
|
||||
if (_cache.TryGetValue(key, out var hit)) return hit;
|
||||
if (_cache.Count > 512) _cache.Clear(); // simple cap; entries are tiny but unbounded is unbounded
|
||||
|
||||
PageTextRuns? runs = null;
|
||||
try
|
||||
{
|
||||
using var doc = PdfDocument.Open(path);
|
||||
if (pageIdx < doc.NumberOfPages)
|
||||
runs = Build(doc.GetPage(pageIdx + 1)); // PdfPig is 1-based
|
||||
}
|
||||
catch { /* encrypted/broken: selection just is not offered on this page */ }
|
||||
|
||||
_cache[key] = runs;
|
||||
return runs;
|
||||
}
|
||||
|
||||
// #185 helper: see the call site comment. Bands arrive top-to-bottom; the result is the
|
||||
// same tuple shape, reordered so the flattened char order reads one column at a time.
|
||||
private static List<(List<UglyToad.PdfPig.Content.Word> Words, double Top, double Bottom)>
|
||||
OrderColumnAware(List<(List<UglyToad.PdfPig.Content.Word> Words, double Top, double Bottom)> bands)
|
||||
{
|
||||
if (bands.Count < 2) return bands;
|
||||
|
||||
double textL = double.MaxValue, textR = double.MinValue;
|
||||
foreach (var (ws, _, _) in bands)
|
||||
foreach (var w in ws)
|
||||
{
|
||||
if (w.BoundingBox.Left < textL) textL = w.BoundingBox.Left;
|
||||
if (w.BoundingBox.Right > textR) textR = w.BoundingBox.Right;
|
||||
}
|
||||
double wideW = (textR - textL) * 0.62; // spans most of the text width = not a column line
|
||||
|
||||
var reordered = new List<(List<UglyToad.PdfPig.Content.Word>, double, double)>();
|
||||
var pending = new List<(List<UglyToad.PdfPig.Content.Word> Words, double Top, double Bottom, double L, double R)>();
|
||||
|
||||
void Flush()
|
||||
{
|
||||
if (pending.Count == 0) return;
|
||||
// Cluster segments into columns by X-interval overlap (>= half the narrower range).
|
||||
var cols = new List<(double L, double R, List<int> Idx)>();
|
||||
var byLeft = Enumerable.Range(0, pending.Count).OrderBy(i => pending[i].L).ToList();
|
||||
foreach (int i in byLeft)
|
||||
{
|
||||
var seg = pending[i];
|
||||
int hit = -1;
|
||||
for (int c = 0; c < cols.Count && hit < 0; c++)
|
||||
{
|
||||
double ov = Math.Min(cols[c].R, seg.R) - Math.Max(cols[c].L, seg.L);
|
||||
double minW = Math.Min(cols[c].R - cols[c].L, seg.R - seg.L);
|
||||
if (minW > 0 && ov >= minW * 0.5) hit = c;
|
||||
}
|
||||
if (hit < 0) cols.Add((seg.L, seg.R, [i]));
|
||||
else
|
||||
{
|
||||
var c0 = cols[hit];
|
||||
c0.Idx.Add(i);
|
||||
cols[hit] = (Math.Min(c0.L, seg.L), Math.Max(c0.R, seg.R), c0.Idx);
|
||||
}
|
||||
}
|
||||
foreach (var col in cols.OrderBy(c => c.L))
|
||||
foreach (int i in col.Idx.OrderByDescending(i => pending[i].Top))
|
||||
reordered.Add((pending[i].Words, pending[i].Top, pending[i].Bottom));
|
||||
pending.Clear();
|
||||
}
|
||||
|
||||
foreach (var (ws, _, _) in bands)
|
||||
{
|
||||
var sorted = ws.OrderBy(w => w.BoundingBox.Left).ToList();
|
||||
// Split threshold: well past a word space (~0.25em) but below a column gutter.
|
||||
double tw = 0; int tn = 0;
|
||||
foreach (var w in sorted) { tw += w.BoundingBox.Width; tn += Math.Max(1, w.Text.Length); }
|
||||
double gapT = Math.Max(10, (tn > 0 ? tw / tn : 5) * 3);
|
||||
|
||||
var segs = new List<List<UglyToad.PdfPig.Content.Word>> { new() { sorted[0] } };
|
||||
for (int i = 1; i < sorted.Count; i++)
|
||||
{
|
||||
if (sorted[i].BoundingBox.Left - sorted[i - 1].BoundingBox.Right > gapT)
|
||||
segs.Add([]);
|
||||
segs[^1].Add(sorted[i]);
|
||||
}
|
||||
foreach (var sws in segs)
|
||||
{
|
||||
double sT = double.MinValue, sB = double.MaxValue, sL = double.MaxValue, sR = double.MinValue;
|
||||
foreach (var w in sws)
|
||||
{
|
||||
if (w.BoundingBox.Top > sT) sT = w.BoundingBox.Top;
|
||||
if (w.BoundingBox.Bottom < sB) sB = w.BoundingBox.Bottom;
|
||||
if (w.BoundingBox.Left < sL) sL = w.BoundingBox.Left;
|
||||
if (w.BoundingBox.Right > sR) sR = w.BoundingBox.Right;
|
||||
}
|
||||
if (sR - sL >= wideW) { Flush(); reordered.Add((sws, sT, sB)); }
|
||||
else pending.Add((sws, sT, sB, sL, sR));
|
||||
}
|
||||
}
|
||||
Flush();
|
||||
return reordered;
|
||||
}
|
||||
|
||||
private static PageTextRuns Build(UglyToad.PdfPig.Content.Page page)
|
||||
{
|
||||
var result = new PageTextRuns { PdfWidth = page.Width, PdfHeight = page.Height };
|
||||
var words = page.GetWords().ToList();
|
||||
if (words.Count == 0) return result;
|
||||
|
||||
// Group words into lines: a word joins a line when its vertical band overlaps the line's
|
||||
// band by at least half the smaller height. Bands grow as members join.
|
||||
var lineWords = new List<(List<UglyToad.PdfPig.Content.Word> Words, double Top, double Bottom)>();
|
||||
foreach (var w in words)
|
||||
{
|
||||
var bb = w.BoundingBox;
|
||||
double wTop = bb.Top, wBottom = bb.Bottom;
|
||||
int found = -1;
|
||||
for (int i = 0; i < lineWords.Count; i++)
|
||||
{
|
||||
var (_, lTop, lBottom) = lineWords[i];
|
||||
double overlap = Math.Min(lTop, wTop) - Math.Max(lBottom, wBottom);
|
||||
double minH = Math.Min(lTop - lBottom, wTop - wBottom);
|
||||
if (minH > 0 && overlap >= minH * 0.5) { found = i; break; }
|
||||
}
|
||||
if (found < 0)
|
||||
lineWords.Add((new List<UglyToad.PdfPig.Content.Word> { w }, wTop, wBottom));
|
||||
else
|
||||
{
|
||||
var entry = lineWords[found];
|
||||
entry.Words.Add(w);
|
||||
lineWords[found] = (entry.Words, Math.Max(entry.Top, wTop), Math.Min(entry.Bottom, wBottom));
|
||||
}
|
||||
}
|
||||
|
||||
// Reading order: lines top-to-bottom (PDF Y grows upward, so larger Top first).
|
||||
// Each line chooses its own horizontal direction so mixed-language pages work too.
|
||||
lineWords.Sort((a, b) => b.Top.CompareTo(a.Top));
|
||||
|
||||
// ---- #185: column-aware reading order ----------------------------------------------
|
||||
// A Y band spans the whole page, so on a two-column layout every "line" mixed both
|
||||
// columns and a drag down one column swept its neighbor. Split each band into segments
|
||||
// at column-gutter-sized gaps, cluster narrow segments into columns by X overlap, and
|
||||
// emit whole columns left-to-right (top-to-bottom inside each). Wide segments - titles
|
||||
// and footers spanning the text width - close the open column section, so a
|
||||
// title / two columns / footer page keeps a sane order. A single-column page yields
|
||||
// one cluster and comes out in exactly the old order.
|
||||
lineWords = OrderColumnAware(lineWords);
|
||||
|
||||
int wordOrdinal = 0;
|
||||
for (int li = 0; li < lineWords.Count; li++)
|
||||
{
|
||||
var (ws, top, bottom) = lineWords[li];
|
||||
bool rtl = IsRightToLeftText(ws.Select(w => w.Text));
|
||||
ws.Sort(rtl
|
||||
? (a, b) => b.BoundingBox.Right.CompareTo(a.BoundingBox.Right)
|
||||
: (a, b) => a.BoundingBox.Left.CompareTo(b.BoundingBox.Left));
|
||||
|
||||
var line = new RunLine
|
||||
{
|
||||
Start = result.Chars.Count,
|
||||
Top = top,
|
||||
Bottom = bottom,
|
||||
RightToLeft = rtl,
|
||||
};
|
||||
foreach (var w in ws)
|
||||
{
|
||||
var letters = w.Letters.ToList();
|
||||
letters.Sort(rtl
|
||||
? (a, b) => b.BoundingBox.Right.CompareTo(a.BoundingBox.Right)
|
||||
: (a, b) => a.BoundingBox.Left.CompareTo(b.BoundingBox.Left));
|
||||
foreach (var letter in letters)
|
||||
{
|
||||
var g = letter.BoundingBox;
|
||||
result.Chars.Add(new RunChar(letter.Value, g.Left, g.Right, wordOrdinal, li));
|
||||
}
|
||||
wordOrdinal++;
|
||||
}
|
||||
line.Count = result.Chars.Count - line.Start;
|
||||
if (line.Count == 0) continue;
|
||||
line.Left = result.Chars.Skip(line.Start).Take(line.Count).Min(c => c.Left);
|
||||
line.Right = result.Chars.Skip(line.Start).Take(line.Count).Max(c => c.Right);
|
||||
result.Lines.Add(line);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static bool IsRightToLeftText(IEnumerable<string> values)
|
||||
{
|
||||
int rtl = 0, ltr = 0;
|
||||
foreach (string value in values)
|
||||
{
|
||||
foreach (char c in value)
|
||||
{
|
||||
if ((c >= '\u0590' && c <= '\u08FF') ||
|
||||
(c >= '\uFB1D' && c <= '\uFDFF') ||
|
||||
(c >= '\uFE70' && c <= '\uFEFF')) rtl++;
|
||||
else if (char.IsLetter(c)) ltr++;
|
||||
}
|
||||
}
|
||||
return rtl > ltr;
|
||||
}
|
||||
|
||||
/// <summary>True when the point sits ON text: inside a line's vertical band and within its
|
||||
/// horizontal extent (small slop). This is the gate that decides flowing selection vs the
|
||||
/// classic marquee - empty page areas must keep the marquee.</summary>
|
||||
public static bool IsOverText(PageTextRuns runs, double x, double y)
|
||||
{
|
||||
const double slop = 2.0; // PDF points
|
||||
foreach (var line in runs.Lines)
|
||||
if (y <= line.Top + slop && y >= line.Bottom - slop &&
|
||||
x >= line.Left - slop && x <= line.Right + slop)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Caret position (0..Chars.Count) nearest a point, browser-style clamping:
|
||||
/// above the first line selects from the page start, below the last line to the page end,
|
||||
/// between lines snaps to the closer line, beyond a line's ends clamps to its ends.</summary>
|
||||
public static int CaretFromPoint(PageTextRuns runs, double x, double y)
|
||||
{
|
||||
if (runs.Lines.Count == 0) return 0;
|
||||
|
||||
RunLine? target = null;
|
||||
double best = double.MaxValue;
|
||||
foreach (var line in runs.Lines)
|
||||
{
|
||||
if (y <= line.Top && y >= line.Bottom) { target = line; best = 0; break; }
|
||||
double d = y > line.Top ? y - line.Top : line.Bottom - y;
|
||||
if (d < best) { best = d; target = line; }
|
||||
}
|
||||
// Above the first line entirely -> caret 0; below the last -> caret N.
|
||||
var first = runs.Lines[0];
|
||||
var last = runs.Lines[runs.Lines.Count - 1];
|
||||
if (y > first.Top && target == first && x < first.Left) return 0;
|
||||
if (y < last.Bottom && target == last && x > last.Right) return runs.Chars.Count;
|
||||
if (target is null) return 0;
|
||||
|
||||
if (target.RightToLeft)
|
||||
{
|
||||
if (x >= target.Right) return target.Start;
|
||||
if (x <= target.Left) return target.End;
|
||||
for (int i = target.Start; i < target.End; i++)
|
||||
{
|
||||
var c = runs.Chars[i];
|
||||
double mid = (c.Left + c.Right) / 2;
|
||||
if (x > mid) return i;
|
||||
}
|
||||
return target.End;
|
||||
}
|
||||
|
||||
if (x <= target.Left) return target.Start;
|
||||
if (x >= target.Right) return target.End;
|
||||
for (int i = target.Start; i < target.End; i++)
|
||||
{
|
||||
var c = runs.Chars[i];
|
||||
double mid = (c.Left + c.Right) / 2;
|
||||
if (x < mid) return i;
|
||||
}
|
||||
return target.End;
|
||||
}
|
||||
|
||||
/// <summary>Text for the caret range [start, end): spaces between words, newlines between
|
||||
/// lines. Also reports how many distinct words the range touches.</summary>
|
||||
public static string TextForRange(PageTextRuns runs, int start, int end, out int wordCount)
|
||||
{
|
||||
wordCount = 0;
|
||||
var sb = new System.Text.StringBuilder();
|
||||
int lastWord = -1, lastLine = -1;
|
||||
for (int i = Math.Max(0, start); i < Math.Min(end, runs.Chars.Count); i++)
|
||||
{
|
||||
var c = runs.Chars[i];
|
||||
if (lastLine >= 0 && c.Line != lastLine) sb.Append('\n');
|
||||
else if (lastWord >= 0 && c.Word != lastWord) sb.Append(' ');
|
||||
if (c.Word != lastWord) wordCount++;
|
||||
sb.Append(c.Value);
|
||||
lastWord = c.Word;
|
||||
lastLine = c.Line;
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user