using System; using System.Collections.Generic; using PdfSharpCore.Drawing; using PdfSharpCore.Pdf; using PdfSharpCore.Pdf.Content; using PdfSharpCore.Pdf.Content.Objects; namespace MmdPdf.Services { // ============================================================ // Removes original page text that sits under an opaque cover. // // When a user edits existing text, the editor lays an opaque CoverAnnotation // over the original run and draws the replacement on top. The page LOOKED // right but the original glyphs were still in the content stream, so text // extraction and copy/paste returned the old value alongside the new one - // an edited quantity on a delivery note stayed recoverable. // // This pass walks the page content stream and blanks every text-showing // operator whose text origin falls inside a cover rectangle, so the old // glyphs are gone from the file, not merely hidden. Positioning operators // are left untouched, so nothing else on the page moves. // // Deliberate limitation: a run is removed only when its ORIGIN is inside the // cover. A single show-text operator that starts outside the cover and runs // into it is left alone rather than risk deleting text the user did not edit. // The editor's own covers are generated to bound the run they replace, so // this is exact for edits made in this application. // ============================================================ internal static class PdfRedactText { /// /// Blanks text drawn with its origin inside any of . /// Rectangles are in PDF user space (origin bottom-left, y up), points. /// Returns the number of show-text operators blanked. /// internal static int RemoveTextUnder(PdfPage page, IReadOnlyList rectsPdf) { if (page is null || rectsPdf is null || rectsPdf.Count == 0) return 0; CSequence content; try { content = ContentReader.ReadContent(page); } catch { return 0; } // unparsable stream: leave the page exactly as it was var state = new TextState(); int blanked = Walk(content, state, rectsPdf); if (blanked == 0) return 0; try { page.Contents.ReplaceContent(content); } catch { return 0; } return blanked; } // ── graphics + text state ────────────────────────────────────────── private sealed class TextState { internal XMatrix Ctm = XMatrix.Identity; internal readonly Stack CtmStack = new Stack(); internal XMatrix Tm = XMatrix.Identity; // text matrix internal XMatrix Tlm = XMatrix.Identity; // text line matrix internal double Leading; // TL } private static int Walk(CSequence seq, TextState st, IReadOnlyList rects) { int blanked = 0; foreach (var obj in seq) { if (obj is CSequence inner && obj is not CArray) { blanked += Walk(inner, st, rects); continue; } if (obj is not COperator op) continue; string name = op.OpCode?.Name ?? ""; switch (name) { case "q": st.CtmStack.Push(st.Ctm); break; case "Q": if (st.CtmStack.Count > 0) st.Ctm = st.CtmStack.Pop(); break; case "cm": if (TryMatrix(op.Operands, out var cm)) st.Ctm = cm * st.Ctm; break; case "BT": st.Tm = st.Tlm = XMatrix.Identity; break; case "ET": st.Tm = st.Tlm = XMatrix.Identity; break; case "Tm": if (TryMatrix(op.Operands, out var tm)) st.Tm = st.Tlm = tm; break; case "TL": st.Leading = Num(op.Operands, 0); break; case "Td": NextLine(st, Num(op.Operands, 0), Num(op.Operands, 1)); break; case "TD": st.Leading = -Num(op.Operands, 1); NextLine(st, Num(op.Operands, 0), Num(op.Operands, 1)); break; case "T*": NextLine(st, 0, -st.Leading); break; case "'": NextLine(st, 0, -st.Leading); blanked += BlankIfInside(op, st, rects); break; case "\"": NextLine(st, 0, -st.Leading); blanked += BlankIfInside(op, st, rects); break; case "Tj": case "TJ": blanked += BlankIfInside(op, st, rects); break; } } return blanked; } private static void NextLine(TextState st, double tx, double ty) { var t = new XMatrix(1, 0, 0, 1, tx, ty); st.Tlm = t * st.Tlm; st.Tm = st.Tlm; } private static int BlankIfInside(COperator op, TextState st, IReadOnlyList rects) { var m = st.Tm * st.Ctm; var origin = new XPoint(m.OffsetX, m.OffsetY); bool hit = false; for (int i = 0; i < rects.Count; i++) { var r = rects[i]; // A baseline sits slightly above the visual bottom of the glyphs, so allow a // small pad below the cover; otherwise a run whose baseline is a point under // the cover edge survives. if (origin.X >= r.X - 1.0 && origin.X <= r.X + r.Width + 1.0 && origin.Y >= r.Y - 2.0 && origin.Y <= r.Y + r.Height + 2.0) { hit = true; break; } } if (!hit) return 0; return BlankStrings(op.Operands) ? 1 : 0; } private static bool BlankStrings(CSequence operands) { if (operands is null) return false; bool any = false; for (int i = 0; i < operands.Count; i++) { switch (operands[i]) { case CString s when s.Value.Length > 0: s.Value = ""; any = true; break; case CArray arr: for (int j = 0; j < arr.Count; j++) { if (arr[j] is CString cs && cs.Value.Length > 0) { cs.Value = ""; any = true; } } break; } } return any; } // ── operand helpers ──────────────────────────────────────────────── private static double Num(CSequence operands, int index) { if (operands is null || index >= operands.Count) return 0; return operands[index] switch { CInteger i => i.Value, CReal r => r.Value, _ => 0 }; } private static bool TryMatrix(CSequence operands, out XMatrix m) { m = XMatrix.Identity; if (operands is null || operands.Count < 6) return false; m = new XMatrix(Num(operands, 0), Num(operands, 1), Num(operands, 2), Num(operands, 3), Num(operands, 4), Num(operands, 5)); return true; } } }