diff --git a/app/editor/tab.py b/app/editor/tab.py
index 8b4afae..fe0f12b 100644
--- a/app/editor/tab.py
+++ b/app/editor/tab.py
@@ -1,7 +1,6 @@
"""PDFApps – TabEditar: visual PDF editor tool tab."""
import contextlib
-import html
import logging
import os
import tempfile
@@ -25,345 +24,12 @@
from app.widgets import DropFileEdit, ColorPickerButton
from app.editor.canvas import PdfEditCanvas, _get_icon_cursor
from app.editor.dialogs import _NoteDialog
+from app.editor.text_reinsert import _reinsert_edited_text
_log = logging.getLogger(__name__)
-# ── high-fidelity text-edit reinsertion ─────────────────────────────────────
-# When the user edits an existing text span the old code redacted the span with
-# an opaque WHITE rectangle and re-typed the text with a base-14 font at an
-# inflated size (``max(size, bbox_height)``), collapsing the family and dropping
-# bold/italic. The helpers below reproduce the original size, weight, colour and
-# — when the source font is fully embedded (non-subset) and covers the new
-# glyphs — the exact typeface, using a transparent redaction (no white box) plus
-# ``Page.insert_htmlbox`` (HarfBuzz shaping + Noto glyph fallback). Everything is
-# wrapped so a failure degrades gracefully to a base-14 ``insert_text`` instead
-# of corrupting the page. See issue #147.
-
-_EMBED_FAMILY = "PDFAppsEmbeddedFont"
-# Legibility floor for the reinserted text. ``insert_htmlbox`` may scale text
-# down to make it fit; below this fraction of the original point size the result
-# is effectively illegible, so we surface a non-blocking warning instead of
-# shrinking silently (issue S1). We keep PyMuPDF's default ``scale_low=0`` (see
-# ``_warn_if_downscaled``) because a positive ``scale_low`` makes insert_htmlbox
-# draw NOTHING on overflow — total text loss, worse than an over-shrunk line.
-_MIN_LEGIBLE_SCALE = 0.6
-# Style tokens stripped (as suffixes) when reducing a PostScript/BaseFont name
-# to a comparable "core" family, so e.g. ``ArialMT`` and ``Arial Regular`` and
-# ``Arial-BoldMT`` all reduce to ``arial``. Matching is only a HINT — the
-# extracted font is still verified glyph-by-glyph before use.
-_FONT_STYLE_SUFFIXES = (
- "regular", "book", "roman", "medium", "semibold", "demibold",
- "bold", "italic", "oblique", "light", "black", "heavy", "condensed",
- "mt", "ps",
-)
-
-
-def _font_core(name):
- """Reduce a font name to a comparable lowercase alphanumeric core."""
- s = "".join(c for c in (name or "").split("+")[-1].lower() if c.isalnum())
- changed = True
- while changed:
- changed = False
- for suf in _FONT_STYLE_SUFFIXES:
- if s.endswith(suf) and len(s) - len(suf) >= 3:
- s = s[:-len(suf)]
- changed = True
- return s
-
-
-def _font_matches(core, basefont, name):
- if not core:
- return False
- for cand in (_font_core(basefont), _font_core(name)):
- if cand and (core == cand or core in cand or cand in core):
- return True
- return False
-
-
-def _text_edit_size(edit):
- """Original span font size (points). ``font_size`` is the true span size;
- ``size`` is the inflated visual size kept for the inline editor. Falls back
- to a small floor only when both are missing/zero."""
- size = float(edit.get("font_size") or 0) or float(edit.get("size") or 0)
- return size if size >= 1.0 else 4.0
-
-
-def _text_edit_color_hex(edit):
- c = edit.get("color", 0)
- if isinstance(c, bool):
- c = 0
- if isinstance(c, int):
- return "#%06x" % (c & 0xFFFFFF)
- if isinstance(c, float):
- return "#%06x" % (int(c) & 0xFFFFFF)
- if c:
- try:
- r, g, b = c[0], c[1], c[2]
- return "#%02x%02x%02x" % (
- max(0, min(255, int(r * 255))),
- max(0, min(255, int(g * 255))),
- max(0, min(255, int(b * 255))))
- except Exception:
- pass
- return "#000000"
-
-
-def _text_edit_color_rgb(edit):
- c = edit.get("color", 0)
- if isinstance(c, bool):
- c = 0
- if isinstance(c, int):
- return (((c >> 16) & 0xFF) / 255, ((c >> 8) & 0xFF) / 255, (c & 0xFF) / 255)
- if c:
- try:
- return (float(c[0]), float(c[1]), float(c[2]))
- except Exception:
- pass
- return (0, 0, 0)
-
-
-def _text_edit_style(edit):
- """Return (serif, mono, bold, italic) from the span's PyMuPDF ``flags``
- (bold=16, italic=2, serif=4, mono=8) complemented by name substrings — the
- flags are documented as an unreliable hint, so we OR in the name heuristic."""
- flags = int(edit.get("flags", 0) or 0)
- fname = (edit.get("font", "") or "").lower()
- serif = bool(flags & 4) or any(
- k in fname for k in ("times", "serif", "roman", "georgia", "garamond", "minion"))
- mono = bool(flags & 8) or any(
- k in fname for k in ("mono", "courier", "consol"))
- bold = bool(flags & 16) or any(
- k in fname for k in ("bold", "black", "heavy", "semibold"))
- italic = bool(flags & 2) or any(
- k in fname for k in ("italic", "oblique"))
- return serif, mono, bold, italic
-
-
-def _base14_fontname(edit):
- """Best-matching base-14 font code for the defensive insert_text fallback."""
- serif, mono, bold, italic = _text_edit_style(edit)
- if mono:
- tbl = {(0, 0): "cour", (1, 0): "cobo", (0, 1): "coit", (1, 1): "cobi"}
- elif serif:
- tbl = {(0, 0): "tiro", (1, 0): "tibo", (0, 1): "tiit", (1, 1): "tibi"}
- else:
- tbl = {(0, 0): "helv", (1, 0): "hebo", (0, 1): "heit", (1, 1): "hebi"}
- return tbl[(int(bold), int(italic))]
-
-
-def _generic_font_style(edit):
- """CSS (family, weight, style) for the generic-family htmlbox fallback."""
- serif, mono, bold, italic = _text_edit_style(edit)
- family = "monospace" if mono else ("serif" if serif else "sans-serif")
- return family, ("bold" if bold else "normal"), ("italic" if italic else "normal")
-
-
-def _build_embed_archive(fitz, doc, page, edit, new_txt):
- """Return ``(archive, ref)`` embedding the span's ORIGINAL font, or
- ``(None, None)``. Only fully embedded (non-subset) fonts whose glyph set
- covers the new text are used; subset fonts (``ABCDEF+`` prefix) are skipped
- because their trimmed cmap/coverage cannot be reliably re-embedded.
-
- MUST be called BEFORE the redaction: ``apply_redactions`` removes the span
- and can drop the now-unreferenced font from ``page.get_fonts()``.
- """
- core = _font_core(edit.get("font", ""))
- if not core:
- return None, None
- try:
- fonts = page.get_fonts(full=False)
- except Exception:
- return None, None
- xref = None
- for f in fonts:
- # get_fonts tuple: (xref, ext, type, basefont, name, encoding)
- fxref, basefont, name = f[0], f[3], f[4]
- if "+" in (basefont or ""):
- continue # subsetted — unreliable to re-embed standalone
- if fxref and int(fxref) > 0 and _font_matches(core, basefont, name):
- xref = int(fxref)
- break
- if not xref:
- return None, None
- try:
- _n, ext_, _t, content = doc.extract_font(xref)
- except Exception:
- return None, None
- if not content or len(content) < 256:
- return None, None
- # Verify the extracted font actually covers every non-space glyph of the
- # new text; otherwise fall through so htmlbox's Noto fallback can kick in.
- try:
- probe = fitz.Font(fontbuffer=content)
- for ch in new_txt:
- if ch.isspace():
- continue
- if not probe.has_glyph(ord(ch)):
- return None, None
- except Exception:
- return None, None
- ref = "pdfapps_embed." + ((ext_ or "ttf").lstrip(".") or "ttf")
- try:
- arch = fitz.Archive()
- arch.add(content, ref)
- except Exception:
- return None, None
- return arch, ref
-
-
-def _text_edit_redaction_rect(fitz, edit, size):
- """Vertically TIGHT redaction rectangle for removing the original span.
-
- PyMuPDF's span ``bbox`` is inflated by the font's ascender/descender —
- often ~1.35x the point size — so redacting the raw bbox of a single line at
- normal (~1.2x) leading reaches into, and erases glyphs of, the lines
- directly above and below (the adjacent-line clipping bug, A1). We instead
- rebuild the band from the baseline with the documented PyMuPDF recipe::
-
- y1 = origin.y - size * descender / (ascender - descender) # descenders
- y0 = y1 - size # ~body top
-
- The band is only ~one point size tall (not the inflated line height) yet it
- still straddles the baseline, so ``apply_redactions`` — which removes a
- glyph whose bbox merely INTERSECTS the rectangle — still deletes every
- target glyph, while neighbours a full line-height away are left untouched.
- Missing/degenerate metrics (or a band that would fall outside the original
- bbox) fall back to the raw bbox, which is always safe for removal."""
- bbox = fitz.Rect(edit["bbox"])
- origin = edit.get("origin") or (bbox.x0, bbox.y1)
- asc = float(edit.get("ascender") or 0)
- desc = float(edit.get("descender") or 0)
- span = asc - desc
- if size > 0 and asc > 0 and desc < 0 and span > 1e-3:
- y1 = float(origin[1]) - size * desc / span
- y0 = y1 - size
- # Adopt the tight band only when it is well-formed AND contained within
- # the inflated bbox (with a hair of slack): this guarantees we never
- # *expand* the deleted area and guards against odd origin/metrics.
- if (y1 - y0) >= size * 0.5 and y0 >= bbox.y0 - 0.5 and y1 <= bbox.y1 + 0.5:
- return fitz.Rect(bbox.x0, y0, bbox.x1, y1)
- return bbox
-
-
-def _text_edit_layout_rect(fitz, page, edit, size):
- """Layout rectangle for insert_htmlbox. insert_htmlbox lays text from the
- TOP of the rect, so we anchor the top at ``origin_y - ascender*size`` (the
- original ascent line) which lands the new baseline within ~1pt of the
- original for the exact font and within a couple of points for a substitute.
- The rect runs to the right page margin (widest single line, avoiding
- scale-down) and extends DOWN to the bottom page margin so longer edited text
- WRAPS at its original size across several lines instead of being silently
- scaled to an illegible size (S1). htmlbox draws only glyphs, never a filled
- box, so the extra height is visually free."""
- bbox = fitz.Rect(edit["bbox"])
- origin = edit.get("origin") or (bbox.x0, bbox.y1)
- asc = float(edit.get("ascender") or 0) or 0.9
- x0 = float(origin[0])
- top = float(origin[1]) - asc * size
- right = page.rect.x1 - 2.0
- if right <= x0 + size:
- right = min(page.rect.x1, x0 + size * 8)
- bottom = max(top + 3.0 * size, page.rect.y1 - 2.0)
- return fitz.Rect(x0, top, right, bottom)
-
-
-def _warn_if_downscaled(htmlbox_result, edit, warn_fn):
- """Inspect ``Page.insert_htmlbox``'s ``(spare_height, scale)`` return value.
-
- We keep the default ``scale_low=0`` so text is never dropped, but a scale
- below ``_MIN_LEGIBLE_SCALE`` (or a reported fit failure) means the edit had
- to shrink to an illegible size. Rather than let that happen silently we log
- it and notify ``warn_fn`` (if given) so the caller can raise a non-blocking
- heads-up. Any unexpected return shape is ignored defensively."""
- try:
- spare_height, scale = htmlbox_result
- scale = float(scale)
- except Exception:
- return
- if scale < _MIN_LEGIBLE_SCALE or (spare_height is not None and spare_height < 0):
- _log.warning(
- "edited text did not fit its box at the original size "
- "(scale=%.2f); it was reduced to fit. old=%r",
- scale, (edit.get("old_text") or "")[:40])
- if warn_fn is not None:
- try:
- warn_fn(edit)
- except Exception:
- _log.exception("text-fit warn_fn raised")
-
-
-def _reinsert_edited_text(fitz, doc, page, edit, warn_fn=None):
- """Redact the original span transparently and reinsert the edited text with
- the original size/weight/colour and — when possible — the exact font.
- Returns True if the original embedded font was reused (so the caller may run
- ``subset_fonts`` afterwards).
-
- ``warn_fn`` (optional): called with ``edit`` when the reinserted text did
- not fit at its original size and had to be scaled below the legibility floor
- — lets the caller surface a non-blocking heads-up instead of an unexplained
- tiny line (S1)."""
- bbox = fitz.Rect(edit["bbox"])
- new_txt = (edit.get("new_text") or "").strip()
- size = _text_edit_size(edit)
- # Capture the source font BEFORE redaction removes the glyphs (and the font).
- arch = ref = None
- if new_txt:
- arch, ref = _build_embed_archive(fitz, doc, page, edit, new_txt)
- # 1) Remove the original glyphs WITHOUT the white-rectangle artifact:
- # a transparent redaction (no fill, no cross-out) that only deletes text
- # (images=0, graphics=0) so a coloured background/line-art survives. The
- # rectangle is a vertically TIGHT body band (not the inflated line-height
- # bbox) so adjacent lines are not clipped — see _text_edit_redaction_rect.
- redact_rect = _text_edit_redaction_rect(fitz, edit, size)
- try:
- page.add_redact_annot(redact_rect, fill=False, cross_out=False)
- page.apply_redactions(images=0, graphics=0, text=0)
- except Exception:
- # Last-resort removal guarantee (previous behaviour): opaque white box.
- page.add_redact_annot(redact_rect, fill=(1, 1, 1))
- page.apply_redactions()
- if not new_txt:
- return False
- color_hex = _text_edit_color_hex(edit)
- rect = _text_edit_layout_rect(fitz, page, edit, size)
- body = "
%s
" % html.escape(new_txt)
- # 2) Reinsert with fidelity. ``white-space:pre-wrap`` preserves the original
- # spacing yet lets long edited text WRAP into the tall box (S1) instead of
- # being scaled down to fit on one line. Any failure degrades to a base-14
- # insert_text at the original baseline (already correct size/weight/
- # colour), never leaving the page corrupted.
- try:
- if arch is not None:
- css = ("@font-face {{ font-family: {fam}; src: url({ref}); }}\n"
- "* {{ margin:0; padding:0; white-space:pre-wrap;"
- " font-family:{fam}; font-size:{sz}pt; color:{col}; }}"
- ).format(fam=_EMBED_FAMILY, ref=ref, sz=size, col=color_hex)
- _warn_if_downscaled(
- page.insert_htmlbox(rect, body, css=css, archive=arch),
- edit, warn_fn)
- return True
- family, weight, style = _generic_font_style(edit)
- css = ("* {{ margin:0; padding:0; white-space:pre-wrap;"
- " font-family:{fam}; font-size:{sz}pt; color:{col};"
- " font-weight:{w}; font-style:{s}; }}"
- ).format(fam=family, sz=size, col=color_hex, w=weight, s=style)
- _warn_if_downscaled(page.insert_htmlbox(rect, body, css=css), edit, warn_fn)
- return False
- except Exception:
- _log.exception("htmlbox reinsertion failed; using base-14 insert_text")
- try:
- origin = edit.get("origin") or (bbox.x0, bbox.y1)
- page.insert_text(fitz.Point(float(origin[0]), float(origin[1])),
- new_txt, fontsize=size,
- fontname=_base14_fontname(edit),
- color=_text_edit_color_rgb(edit))
- except Exception:
- _log.exception("base-14 insert_text fallback also failed")
- return False
-
-
# Mode indices in `_mode_btns` — kept as constants for readability so
# call-sites like `if self._mode_idx == _MODE_FORMS:` document intent
# without forcing a refactor of the existing numeric layout.
diff --git a/app/editor/text_reinsert.py b/app/editor/text_reinsert.py
new file mode 100644
index 0000000..e24d277
--- /dev/null
+++ b/app/editor/text_reinsert.py
@@ -0,0 +1,346 @@
+"""PDFApps – pure typographic-fidelity helpers for text-edit reinsertion.
+
+High-fidelity text-edit reinsertion. When the user edits an existing text span
+the old code redacted the span with an opaque WHITE rectangle and re-typed the
+text with a base-14 font at an inflated size (``max(size, bbox_height)``),
+collapsing the family and dropping bold/italic. The helpers below reproduce the
+original size, weight, colour and — when the source font is fully embedded
+(non-subset) and covers the new glyphs — the exact typeface, using a transparent
+redaction (no white box) plus ``Page.insert_htmlbox`` (HarfBuzz shaping + Noto
+glyph fallback). Everything is wrapped so a failure degrades gracefully to a
+base-14 ``insert_text`` instead of corrupting the page. See issue #147.
+
+These functions are pure (no Qt/UI/``self`` state): PyMuPDF (``fitz``) and the
+document/page objects are passed in as arguments, so they can be unit-tested
+headless. Kept in a dedicated module (extracted from ``app.editor.tab``) to keep
+the UI tab lean; ``app.editor.tab`` re-uses ``_reinsert_edited_text`` from here.
+"""
+
+import html
+import logging
+
+
+_log = logging.getLogger(__name__)
+
+
+_EMBED_FAMILY = "PDFAppsEmbeddedFont"
+# Legibility floor for the reinserted text. ``insert_htmlbox`` may scale text
+# down to make it fit; below this fraction of the original point size the result
+# is effectively illegible, so we surface a non-blocking warning instead of
+# shrinking silently (issue S1). We keep PyMuPDF's default ``scale_low=0`` (see
+# ``_warn_if_downscaled``) because a positive ``scale_low`` makes insert_htmlbox
+# draw NOTHING on overflow — total text loss, worse than an over-shrunk line.
+_MIN_LEGIBLE_SCALE = 0.6
+# Style tokens stripped (as suffixes) when reducing a PostScript/BaseFont name
+# to a comparable "core" family, so e.g. ``ArialMT`` and ``Arial Regular`` and
+# ``Arial-BoldMT`` all reduce to ``arial``. Matching is only a HINT — the
+# extracted font is still verified glyph-by-glyph before use.
+_FONT_STYLE_SUFFIXES = (
+ "regular", "book", "roman", "medium", "semibold", "demibold",
+ "bold", "italic", "oblique", "light", "black", "heavy", "condensed",
+ "mt", "ps",
+)
+
+
+def _font_core(name):
+ """Reduce a font name to a comparable lowercase alphanumeric core."""
+ s = "".join(c for c in (name or "").split("+")[-1].lower() if c.isalnum())
+ changed = True
+ while changed:
+ changed = False
+ for suf in _FONT_STYLE_SUFFIXES:
+ if s.endswith(suf) and len(s) - len(suf) >= 3:
+ s = s[:-len(suf)]
+ changed = True
+ return s
+
+
+def _font_matches(core, basefont, name):
+ if not core:
+ return False
+ for cand in (_font_core(basefont), _font_core(name)):
+ if cand and (core == cand or core in cand or cand in core):
+ return True
+ return False
+
+
+def _text_edit_size(edit):
+ """Original span font size (points). ``font_size`` is the true span size;
+ ``size`` is the inflated visual size kept for the inline editor. Falls back
+ to a small floor only when both are missing/zero."""
+ size = float(edit.get("font_size") or 0) or float(edit.get("size") or 0)
+ return size if size >= 1.0 else 4.0
+
+
+def _text_edit_color_hex(edit):
+ c = edit.get("color", 0)
+ if isinstance(c, bool):
+ c = 0
+ if isinstance(c, int):
+ return "#%06x" % (c & 0xFFFFFF)
+ if isinstance(c, float):
+ return "#%06x" % (int(c) & 0xFFFFFF)
+ if c:
+ try:
+ r, g, b = c[0], c[1], c[2]
+ return "#%02x%02x%02x" % (
+ max(0, min(255, int(r * 255))),
+ max(0, min(255, int(g * 255))),
+ max(0, min(255, int(b * 255))))
+ except Exception:
+ pass
+ return "#000000"
+
+
+def _text_edit_color_rgb(edit):
+ c = edit.get("color", 0)
+ if isinstance(c, bool):
+ c = 0
+ if isinstance(c, int):
+ return (((c >> 16) & 0xFF) / 255, ((c >> 8) & 0xFF) / 255, (c & 0xFF) / 255)
+ if c:
+ try:
+ return (float(c[0]), float(c[1]), float(c[2]))
+ except Exception:
+ pass
+ return (0, 0, 0)
+
+
+def _text_edit_style(edit):
+ """Return (serif, mono, bold, italic) from the span's PyMuPDF ``flags``
+ (bold=16, italic=2, serif=4, mono=8) complemented by name substrings — the
+ flags are documented as an unreliable hint, so we OR in the name heuristic."""
+ flags = int(edit.get("flags", 0) or 0)
+ fname = (edit.get("font", "") or "").lower()
+ serif = bool(flags & 4) or any(
+ k in fname for k in ("times", "serif", "roman", "georgia", "garamond", "minion"))
+ mono = bool(flags & 8) or any(
+ k in fname for k in ("mono", "courier", "consol"))
+ bold = bool(flags & 16) or any(
+ k in fname for k in ("bold", "black", "heavy", "semibold"))
+ italic = bool(flags & 2) or any(
+ k in fname for k in ("italic", "oblique"))
+ return serif, mono, bold, italic
+
+
+def _base14_fontname(edit):
+ """Best-matching base-14 font code for the defensive insert_text fallback."""
+ serif, mono, bold, italic = _text_edit_style(edit)
+ if mono:
+ tbl = {(0, 0): "cour", (1, 0): "cobo", (0, 1): "coit", (1, 1): "cobi"}
+ elif serif:
+ tbl = {(0, 0): "tiro", (1, 0): "tibo", (0, 1): "tiit", (1, 1): "tibi"}
+ else:
+ tbl = {(0, 0): "helv", (1, 0): "hebo", (0, 1): "heit", (1, 1): "hebi"}
+ return tbl[(int(bold), int(italic))]
+
+
+def _generic_font_style(edit):
+ """CSS (family, weight, style) for the generic-family htmlbox fallback."""
+ serif, mono, bold, italic = _text_edit_style(edit)
+ family = "monospace" if mono else ("serif" if serif else "sans-serif")
+ return family, ("bold" if bold else "normal"), ("italic" if italic else "normal")
+
+
+def _build_embed_archive(fitz, doc, page, edit, new_txt):
+ """Return ``(archive, ref)`` embedding the span's ORIGINAL font, or
+ ``(None, None)``. Only fully embedded (non-subset) fonts whose glyph set
+ covers the new text are used; subset fonts (``ABCDEF+`` prefix) are skipped
+ because their trimmed cmap/coverage cannot be reliably re-embedded.
+
+ MUST be called BEFORE the redaction: ``apply_redactions`` removes the span
+ and can drop the now-unreferenced font from ``page.get_fonts()``.
+ """
+ core = _font_core(edit.get("font", ""))
+ if not core:
+ return None, None
+ try:
+ fonts = page.get_fonts(full=False)
+ except Exception:
+ return None, None
+ xref = None
+ for f in fonts:
+ # get_fonts tuple: (xref, ext, type, basefont, name, encoding)
+ fxref, basefont, name = f[0], f[3], f[4]
+ if "+" in (basefont or ""):
+ continue # subsetted — unreliable to re-embed standalone
+ if fxref and int(fxref) > 0 and _font_matches(core, basefont, name):
+ xref = int(fxref)
+ break
+ if not xref:
+ return None, None
+ try:
+ _n, ext_, _t, content = doc.extract_font(xref)
+ except Exception:
+ return None, None
+ if not content or len(content) < 256:
+ return None, None
+ # Verify the extracted font actually covers every non-space glyph of the
+ # new text; otherwise fall through so htmlbox's Noto fallback can kick in.
+ try:
+ probe = fitz.Font(fontbuffer=content)
+ for ch in new_txt:
+ if ch.isspace():
+ continue
+ if not probe.has_glyph(ord(ch)):
+ return None, None
+ except Exception:
+ return None, None
+ ref = "pdfapps_embed." + ((ext_ or "ttf").lstrip(".") or "ttf")
+ try:
+ arch = fitz.Archive()
+ arch.add(content, ref)
+ except Exception:
+ return None, None
+ return arch, ref
+
+
+def _text_edit_redaction_rect(fitz, edit, size):
+ """Vertically TIGHT redaction rectangle for removing the original span.
+
+ PyMuPDF's span ``bbox`` is inflated by the font's ascender/descender —
+ often ~1.35x the point size — so redacting the raw bbox of a single line at
+ normal (~1.2x) leading reaches into, and erases glyphs of, the lines
+ directly above and below (the adjacent-line clipping bug, A1). We instead
+ rebuild the band from the baseline with the documented PyMuPDF recipe::
+
+ y1 = origin.y - size * descender / (ascender - descender) # descenders
+ y0 = y1 - size # ~body top
+
+ The band is only ~one point size tall (not the inflated line height) yet it
+ still straddles the baseline, so ``apply_redactions`` — which removes a
+ glyph whose bbox merely INTERSECTS the rectangle — still deletes every
+ target glyph, while neighbours a full line-height away are left untouched.
+ Missing/degenerate metrics (or a band that would fall outside the original
+ bbox) fall back to the raw bbox, which is always safe for removal."""
+ bbox = fitz.Rect(edit["bbox"])
+ origin = edit.get("origin") or (bbox.x0, bbox.y1)
+ asc = float(edit.get("ascender") or 0)
+ desc = float(edit.get("descender") or 0)
+ span = asc - desc
+ if size > 0 and asc > 0 and desc < 0 and span > 1e-3:
+ y1 = float(origin[1]) - size * desc / span
+ y0 = y1 - size
+ # Adopt the tight band only when it is well-formed AND contained within
+ # the inflated bbox (with a hair of slack): this guarantees we never
+ # *expand* the deleted area and guards against odd origin/metrics.
+ if (y1 - y0) >= size * 0.5 and y0 >= bbox.y0 - 0.5 and y1 <= bbox.y1 + 0.5:
+ return fitz.Rect(bbox.x0, y0, bbox.x1, y1)
+ return bbox
+
+
+def _text_edit_layout_rect(fitz, page, edit, size):
+ """Layout rectangle for insert_htmlbox. insert_htmlbox lays text from the
+ TOP of the rect, so we anchor the top at ``origin_y - ascender*size`` (the
+ original ascent line) which lands the new baseline within ~1pt of the
+ original for the exact font and within a couple of points for a substitute.
+ The rect runs to the right page margin (widest single line, avoiding
+ scale-down) and extends DOWN to the bottom page margin so longer edited text
+ WRAPS at its original size across several lines instead of being silently
+ scaled to an illegible size (S1). htmlbox draws only glyphs, never a filled
+ box, so the extra height is visually free."""
+ bbox = fitz.Rect(edit["bbox"])
+ origin = edit.get("origin") or (bbox.x0, bbox.y1)
+ asc = float(edit.get("ascender") or 0) or 0.9
+ x0 = float(origin[0])
+ top = float(origin[1]) - asc * size
+ right = page.rect.x1 - 2.0
+ if right <= x0 + size:
+ right = min(page.rect.x1, x0 + size * 8)
+ bottom = max(top + 3.0 * size, page.rect.y1 - 2.0)
+ return fitz.Rect(x0, top, right, bottom)
+
+
+def _warn_if_downscaled(htmlbox_result, edit, warn_fn):
+ """Inspect ``Page.insert_htmlbox``'s ``(spare_height, scale)`` return value.
+
+ We keep the default ``scale_low=0`` so text is never dropped, but a scale
+ below ``_MIN_LEGIBLE_SCALE`` (or a reported fit failure) means the edit had
+ to shrink to an illegible size. Rather than let that happen silently we log
+ it and notify ``warn_fn`` (if given) so the caller can raise a non-blocking
+ heads-up. Any unexpected return shape is ignored defensively."""
+ try:
+ spare_height, scale = htmlbox_result
+ scale = float(scale)
+ except Exception:
+ return
+ if scale < _MIN_LEGIBLE_SCALE or (spare_height is not None and spare_height < 0):
+ _log.warning(
+ "edited text did not fit its box at the original size "
+ "(scale=%.2f); it was reduced to fit. old=%r",
+ scale, (edit.get("old_text") or "")[:40])
+ if warn_fn is not None:
+ try:
+ warn_fn(edit)
+ except Exception:
+ _log.exception("text-fit warn_fn raised")
+
+
+def _reinsert_edited_text(fitz, doc, page, edit, warn_fn=None):
+ """Redact the original span transparently and reinsert the edited text with
+ the original size/weight/colour and — when possible — the exact font.
+ Returns True if the original embedded font was reused (so the caller may run
+ ``subset_fonts`` afterwards).
+
+ ``warn_fn`` (optional): called with ``edit`` when the reinserted text did
+ not fit at its original size and had to be scaled below the legibility floor
+ — lets the caller surface a non-blocking heads-up instead of an unexplained
+ tiny line (S1)."""
+ bbox = fitz.Rect(edit["bbox"])
+ new_txt = (edit.get("new_text") or "").strip()
+ size = _text_edit_size(edit)
+ # Capture the source font BEFORE redaction removes the glyphs (and the font).
+ arch = ref = None
+ if new_txt:
+ arch, ref = _build_embed_archive(fitz, doc, page, edit, new_txt)
+ # 1) Remove the original glyphs WITHOUT the white-rectangle artifact:
+ # a transparent redaction (no fill, no cross-out) that only deletes text
+ # (images=0, graphics=0) so a coloured background/line-art survives. The
+ # rectangle is a vertically TIGHT body band (not the inflated line-height
+ # bbox) so adjacent lines are not clipped — see _text_edit_redaction_rect.
+ redact_rect = _text_edit_redaction_rect(fitz, edit, size)
+ try:
+ page.add_redact_annot(redact_rect, fill=False, cross_out=False)
+ page.apply_redactions(images=0, graphics=0, text=0)
+ except Exception:
+ # Last-resort removal guarantee (previous behaviour): opaque white box.
+ page.add_redact_annot(redact_rect, fill=(1, 1, 1))
+ page.apply_redactions()
+ if not new_txt:
+ return False
+ color_hex = _text_edit_color_hex(edit)
+ rect = _text_edit_layout_rect(fitz, page, edit, size)
+ body = "%s
" % html.escape(new_txt)
+ # 2) Reinsert with fidelity. ``white-space:pre-wrap`` preserves the original
+ # spacing yet lets long edited text WRAP into the tall box (S1) instead of
+ # being scaled down to fit on one line. Any failure degrades to a base-14
+ # insert_text at the original baseline (already correct size/weight/
+ # colour), never leaving the page corrupted.
+ try:
+ if arch is not None:
+ css = ("@font-face {{ font-family: {fam}; src: url({ref}); }}\n"
+ "* {{ margin:0; padding:0; white-space:pre-wrap;"
+ " font-family:{fam}; font-size:{sz}pt; color:{col}; }}"
+ ).format(fam=_EMBED_FAMILY, ref=ref, sz=size, col=color_hex)
+ _warn_if_downscaled(
+ page.insert_htmlbox(rect, body, css=css, archive=arch),
+ edit, warn_fn)
+ return True
+ family, weight, style = _generic_font_style(edit)
+ css = ("* {{ margin:0; padding:0; white-space:pre-wrap;"
+ " font-family:{fam}; font-size:{sz}pt; color:{col};"
+ " font-weight:{w}; font-style:{s}; }}"
+ ).format(fam=family, sz=size, col=color_hex, w=weight, s=style)
+ _warn_if_downscaled(page.insert_htmlbox(rect, body, css=css), edit, warn_fn)
+ return False
+ except Exception:
+ _log.exception("htmlbox reinsertion failed; using base-14 insert_text")
+ try:
+ origin = edit.get("origin") or (bbox.x0, bbox.y1)
+ page.insert_text(fitz.Point(float(origin[0]), float(origin[1])),
+ new_txt, fontsize=size,
+ fontname=_base14_fontname(edit),
+ color=_text_edit_color_rgb(edit))
+ except Exception:
+ _log.exception("base-14 insert_text fallback also failed")
+ return False
diff --git a/tests/test_editor_text_fidelity.py b/tests/test_editor_text_fidelity.py
index cdd3528..272941b 100644
--- a/tests/test_editor_text_fidelity.py
+++ b/tests/test_editor_text_fidelity.py
@@ -27,7 +27,7 @@
pymupdf = pytest.importorskip("pymupdf")
fitz = pymupdf
-from app.editor.tab import ( # noqa: E402
+from app.editor.text_reinsert import ( # noqa: E402
_font_core, _font_matches, _text_edit_size, _text_edit_color_hex,
_text_edit_color_rgb, _base14_fontname, _reinsert_edited_text,
_text_edit_redaction_rect, _MIN_LEGIBLE_SCALE,