namespace EnvelopeRenderer.Cli.Render; /// /// Pure geometry for Sprint 4's "Collapse blank optional address lines consistently": given a /// set of text lines (each with an X/Y position and whether it should collapse for this /// particular record), decides which lines are visible and where the visible ones land after /// closing the gaps left by any collapsed lines above them. /// /// Grouping rule (a Development Team design decision — the template format has no /// explicit "address block" concept): lines that share the same X position (rounded to 2 /// decimal places, matching the precision the designer's properties panel and XML persistence /// already use) are treated as one vertical stack — i.e. one address block — ordered top-to- /// bottom by descending Y (PDF points increase upward, so the top line of a block has the /// highest Y). Lines at a different X never affect each other's collapsing math. This mirrors /// how a real address block is authored today: every line in it shares the same left margin. /// /// Shift math: for a stack ordered top-to-bottom, line k's "row height" is defined /// as the gap between it and the line immediately below it, i.e. Y[k] - Y[k+1] (using the /// stack's original, undisturbed Y values — never an already-shifted one, so consecutive /// collapsed lines don't compound rounding error). When line k collapses, every line below it in /// the same stack shifts upward by that row height. The last line in a stack has no line below /// it, so its own row height is irrelevant (nothing shifts if it collapses) — a deliberate /// no-op, not a bug. /// public static class AddressLineCollapser { /// One input line: its original template position and whether this particular /// record's resolved value makes it collapse (i.e. the caller has already combined "is this /// element configured as collapsible" with "is its resolved text blank for this record"). public readonly record struct Line(double X, double Y, bool ShouldCollapse); /// One resolved line, same order/count as the input. is /// false exactly for lines whose own was true — the /// caller should skip drawing those. is the Y every line (visible or /// not) should be treated as occupying after any collapsed lines above it in its stack have /// closed their gaps. public readonly record struct Resolved(bool Visible, double EffectiveY); /// Rounding precision (decimal places) used to decide whether two lines share the /// same X position and therefore belong to the same address-line stack. 2 decimal places /// matches the precision already used by the designer's XML persistence and properties /// panel, so two lines placed at "the same" X via the UI reliably group together even after a /// save/reload round trip through floating point. private const int GroupingPrecision = 2; public static IReadOnlyList Resolve(IReadOnlyList lines) { var result = new Resolved[lines.Count]; var groups = lines .Select((line, index) => (Line: line, Index: index)) .GroupBy(t => Math.Round(t.Line.X, GroupingPrecision)); foreach (var group in groups) { var stack = group.OrderByDescending(t => t.Line.Y).ToList(); var cumulativeShift = 0.0; for (var i = 0; i < stack.Count; i++) { var (line, index) = stack[i]; result[index] = new Resolved(!line.ShouldCollapse, line.Y + cumulativeShift); if (line.ShouldCollapse && i < stack.Count - 1) { var nextY = stack[i + 1].Line.Y; cumulativeShift += line.Y - nextY; } } } return result; } }