diff --git a/app/editor/canvas.py b/app/editor/canvas.py
index fc57817..9348230 100644
--- a/app/editor/canvas.py
+++ b/app/editor/canvas.py
@@ -470,13 +470,23 @@ def _commit_inline(self):
if new_text == original:
return
bb = span["bbox"]
+ # ``visual_size`` (inflated to the bbox height) styles the inline
+ # QLineEdit only. ``font_size`` is the TRUE span size the save path
+ # must reinsert at — using the inflated size was the #147 size bug.
visual_size = max(float(span.get("size") or 0), float(bb[3] - bb[1]))
edit = {
"type": "text_edit", "page": page_idx,
"bbox": list(bb), "old_text": span.get("text", ""),
"new_text": new_text, "size": visual_size,
+ "font_size": float(span.get("size") or 0),
"color": span.get("color", 0),
"font": span.get("font", ""),
+ # PyMuPDF span flags (bold=16, italic=2, serif=4, mono=8) and
+ # font metrics let the save path reproduce weight/style and
+ # place the new baseline near the original. See #147.
+ "flags": int(span.get("flags", 0) or 0),
+ "ascender": float(span.get("ascender") or 0),
+ "descender": float(span.get("descender") or 0),
"origin": list(span.get("origin", (bb[0], bb[3]))),
}
self.text_edit_committed.emit(page_idx, edit)
diff --git a/app/editor/tab.py b/app/editor/tab.py
index 25d05bf..8446ba2 100644
--- a/app/editor/tab.py
+++ b/app/editor/tab.py
@@ -1,6 +1,7 @@
"""PDFApps – TabEditar: visual PDF editor tool tab."""
import contextlib
+import html
import logging
import os
import tempfile
@@ -29,6 +30,340 @@
_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.
@@ -1237,6 +1572,12 @@ def _run(self):
)
if _non_latin:
self._status(t("tool.warn.font_latin_only"))
+ embedded_font = False # any text_edit that re-embedded its font
+ # Edits whose new text could not keep its original size (S1). Each
+ # entry is the edit dict; a non-empty list raises a non-blocking
+ # heads-up after the save so the user is never left with an
+ # unexplained illegibly-shrunk line.
+ text_fit_warnings = []
for e in self._pending:
if e.get("_existing") and e.get("type") != "delete_annot":
continue # already saved in the PDF
@@ -1285,29 +1626,20 @@ def _run(self):
pg.delete_annot(annot)
break
elif e["type"] == "text_edit":
- bbox = fitz.Rect(e["bbox"])
- pg.add_redact_annot(bbox, fill=(1, 1, 1))
- pg.apply_redactions()
- new_txt = e.get("new_text", "").strip()
- if new_txt:
- c = e.get("color", 0)
- if isinstance(c, int):
- color = (((c>>16)&0xFF)/255, ((c>>8)&0xFF)/255, (c&0xFF)/255)
- else:
- color = c if c else (0, 0, 0)
- orig = e.get("origin") or (bbox.x0, bbox.y1)
- fname = (e.get("font", "") or "").lower()
- if "times" in fname or "serif" in fname or "roman" in fname:
- fontname = "tiro"
- elif "mono" in fname or "courier" in fname or "consol" in fname:
- fontname = "cour"
- else:
- fontname = "helv"
- bbox_h = bbox.y1 - bbox.y0
- fontsize = max(4.0, float(e.get("size") or 0), bbox_h)
- pg.insert_text(fitz.Point(orig[0], orig[1]),
- new_txt, fontsize=fontsize,
- fontname=fontname, color=color)
+ # High-fidelity reinsertion: transparent redaction (no white
+ # box) + insert_htmlbox preserving the original size, weight,
+ # colour and — when the source font is embeddable — the exact
+ # typeface, with a defensive base-14 fallback. See #147.
+ if _reinsert_edited_text(fitz, doc, pg, e,
+ warn_fn=text_fit_warnings.append):
+ embedded_font = True
+ if embedded_font:
+ # Subset the freshly embedded fonts to keep the file small.
+ # Best-effort: never let optimisation abort a valid save.
+ try:
+ doc.subset_fonts()
+ except Exception:
+ _log.exception("subset_fonts after text edit failed")
fd, tmp = tempfile.mkstemp(prefix=".pdfapps_save_", suffix=".pdf",
dir=os.path.dirname(out) or ".")
os.close(fd)
@@ -1341,7 +1673,16 @@ def _run(self):
raise
self._pending.clear(); self._pending_list.clear()
self._status(t("edit.status.saved", path=out))
- QMessageBox.information(self, t("msg.done"), t("msg.pdf_saved", path=out))
+ if text_fit_warnings:
+ # Some edited text could not keep its original size (it was
+ # reduced to fit its line). The save still succeeded — flag it
+ # with a warning-styled dialog (reusing existing translated
+ # strings) so the user knows to review those lines.
+ QMessageBox.warning(self, t("msg.warning"),
+ t("msg.pdf_saved", path=out))
+ else:
+ QMessageBox.information(self, t("msg.done"),
+ t("msg.pdf_saved", path=out))
# Reload the saved file
self._load_pdf(out)
except Exception as e:
diff --git a/tests/test_editor_text_fidelity.py b/tests/test_editor_text_fidelity.py
new file mode 100644
index 0000000..cdd3528
--- /dev/null
+++ b/tests/test_editor_text_fidelity.py
@@ -0,0 +1,468 @@
+"""High-fidelity text-edit reinsertion (issue #147, "Nível A").
+
+These tests exercise the save-path helpers that replace an edited text span.
+The old behaviour (regressions these tests would catch):
+
+ * font size was inflated to ``max(size, bbox_height)`` (line-height, not size);
+ * bold / italic were dropped (always plain base-14);
+ * the family collapsed to helv/tiro/cour;
+ * the original span was covered with an OPAQUE WHITE rectangle, leaving a
+ white block over non-white backgrounds.
+
+The new pipeline uses a transparent redaction plus ``Page.insert_htmlbox`` with
+an ``@font-face`` archive (exact font when embeddable) or a generic family with
+Noto glyph fallback, degrading to a base-14 ``insert_text`` on any failure.
+
+Run with ``QT_QPA_PLATFORM=offscreen`` (the helpers are pure, no widgets).
+"""
+
+import os
+import sys
+from pathlib import Path
+
+import pytest
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+pymupdf = pytest.importorskip("pymupdf")
+fitz = pymupdf
+
+from app.editor.tab 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,
+)
+
+_DEJAVU = "C:/Windows/Fonts/DejaVuSans.ttf"
+
+
+# ── helpers ──────────────────────────────────────────────────────────────
+
+def _first_span(page):
+ for block in page.get_text("dict")["blocks"]:
+ if block.get("type") != 0:
+ continue
+ for line in block.get("lines", []):
+ for span in line.get("spans", []):
+ return span
+ return None
+
+
+def _edit_from_span(span, new_text, page_idx=0):
+ """Build the edit dict exactly as canvas._commit_inline emits it."""
+ bb = span["bbox"]
+ return {
+ "type": "text_edit", "page": page_idx,
+ "bbox": list(bb), "old_text": span.get("text", ""),
+ "new_text": new_text,
+ "size": max(float(span.get("size") or 0), float(bb[3] - bb[1])),
+ "font_size": float(span.get("size") or 0),
+ "color": span.get("color", 0),
+ "font": span.get("font", ""),
+ "flags": int(span.get("flags", 0) or 0),
+ "ascender": float(span.get("ascender") or 0),
+ "descender": float(span.get("descender") or 0),
+ "origin": list(span.get("origin", (bb[0], bb[3]))),
+ }
+
+
+def _page_with_text(text, fontname, size, color=(0, 0, 0), *,
+ width=400, height=200, at=(30, 100), bg=None):
+ doc = fitz.open()
+ page = doc.new_page(width=width, height=height)
+ if bg is not None:
+ page.draw_rect(fitz.Rect(10, 10, width - 10, height - 40),
+ color=bg, fill=bg)
+ page.insert_text(at, text, fontsize=size, fontname=fontname, color=color)
+ return doc, page
+
+
+def _page_with_lines(lines, fontname, size, *, leading=1.2, at=(30, 60),
+ width=320, height=220):
+ """Render several lines at NORMAL (~1.2x) leading; return (doc, page)."""
+ doc = fitz.open()
+ page = doc.new_page(width=width, height=height)
+ x, y0 = at
+ for i, line in enumerate(lines):
+ page.insert_text((x, y0 + i * leading * size), line,
+ fontsize=size, fontname=fontname)
+ return doc, page
+
+
+def _find_span(page, needle):
+ for block in page.get_text("dict")["blocks"]:
+ if block.get("type") != 0:
+ continue
+ for line in block.get("lines", []):
+ for span in line.get("spans", []):
+ if needle in span.get("text", ""):
+ return span
+ return None
+
+
+def _edit_roundtrip(doc, page, new_text):
+ """Apply the edit, save+reopen through bytes, return the resulting span."""
+ span = _first_span(page)
+ edit = _edit_from_span(span, new_text)
+ embedded = _reinsert_edited_text(fitz, doc, page, edit)
+ data = doc.tobytes(garbage=4, deflate=True)
+ doc.close()
+ reopened = fitz.open("pdf", data)
+ result = _first_span(reopened[0])
+ reopened.close()
+ return edit, embedded, result
+
+
+# ── font-name matching (unit) ────────────────────────────────────────────
+
+@pytest.mark.parametrize("name,expected", [
+ ("ArialMT", "arial"),
+ ("Arial-BoldMT", "arial"),
+ ("DejaVuSans", "dejavusans"),
+ ("DejaVu Sans Book", "dejavusans"),
+ ("Times-Roman", "times"),
+ ("ABCDEF+Calibri-Bold", "calibri"),
+])
+def test_font_core_reduces_style_suffixes(name, expected):
+ assert _font_core(name) == expected
+
+
+@pytest.mark.parametrize("span_font,basefont,name,expect", [
+ ("DejaVuSans", "DejaVu Sans Book", "DVS", True),
+ ("ArialMT", "Arial Regular", "F0", True),
+ ("Arial-BoldMT", "Arial Bold", "F1", True),
+ ("Calibri", "Calibri Regular", "F2", True),
+ ("ArialMT", "Calibri Regular", "F3", False),
+])
+def test_font_matches(span_font, basefont, name, expect):
+ assert _font_matches(_font_core(span_font), basefont, name) is expect
+
+
+# ── size / colour parsing (unit) ─────────────────────────────────────────
+
+def test_size_uses_raw_span_size_not_inflated():
+ # font_size=10 but the inflated "size" is 18 (bbox height). Must pick 10.
+ assert _text_edit_size({"font_size": 10.0, "size": 18.0}) == 10.0
+
+
+def test_size_floor_when_missing():
+ assert _text_edit_size({"font_size": 0, "size": 0}) == 4.0
+
+
+def test_color_hex_from_srgb_int():
+ assert _text_edit_color_hex({"color": 0xB31A1A}) == "#b31a1a"
+ assert _text_edit_color_hex({"color": 0}) == "#000000"
+
+
+def test_color_rgb_from_int():
+ r, g, b = _text_edit_color_rgb({"color": 0xFF0000})
+ assert (round(r), round(g), round(b)) == (1, 0, 0)
+
+
+@pytest.mark.parametrize("edit,expected", [
+ ({"flags": 0, "font": "Helvetica"}, "helv"),
+ ({"flags": 16, "font": "Helvetica-Bold"}, "hebo"),
+ ({"flags": 2, "font": "Helvetica-Oblique"}, "heit"),
+ ({"flags": 4, "font": "Times-Roman"}, "tiro"),
+ ({"flags": 16 | 4, "font": "Times-Bold"}, "tibo"),
+ ({"flags": 8, "font": "Courier"}, "cour"),
+])
+def test_base14_fontname_variants(edit, expected):
+ assert _base14_fontname(edit) == expected
+
+
+# ── discriminative save-path tests ───────────────────────────────────────
+
+def test_size_preserved_not_inflated():
+ """A size-12 span must reinsert at ~12pt, not the inflated bbox height."""
+ doc, page = _page_with_text("Original", "helv", 12)
+ _edit, _embedded, result = _edit_roundtrip(doc, page, "Modified")
+ assert result is not None
+ assert result["size"] == pytest.approx(12.0, abs=0.6)
+
+
+def test_color_preserved():
+ doc, page = _page_with_text("Original", "helv", 14, color=(0.7, 0.1, 0.1))
+ _edit, _embedded, result = _edit_roundtrip(doc, page, "Recoloured")
+ r = (result["color"] >> 16) & 0xFF
+ g = (result["color"] >> 8) & 0xFF
+ b = result["color"] & 0xFF
+ assert r > 150 and g < 60 and b < 60
+
+
+def test_bold_preserved():
+ doc, page = _page_with_text("Bold", "hebo", 16)
+ _edit, _embedded, result = _edit_roundtrip(doc, page, "StillBold")
+ # PyMuPDF bold flag is bit 4 (value 16).
+ assert result["flags"] & 16, "bold weight was dropped"
+
+
+def test_italic_preserved():
+ doc, page = _page_with_text("Ital", "tiit", 16)
+ _edit, _embedded, result = _edit_roundtrip(doc, page, "StillItalic")
+ # italic flag is bit 1 (value 2).
+ assert result["flags"] & 2, "italic style was dropped"
+
+
+def test_baseline_position_near_original():
+ doc, page = _page_with_text("Original", "helv", 14)
+ span = _first_span(page)
+ orig_oy = span["origin"][1]
+ _edit, _embedded, result = _edit_roundtrip(doc, page, "Moved")
+ # generic-substitute font: baseline within a few points of the original.
+ assert abs(result["origin"][1] - orig_oy) < 3.0
+
+
+def test_transparent_redaction_preserves_background():
+ """No opaque white box: a coloured background survives under the edit."""
+ bg = (0.85, 0.30, 0.30)
+ doc, page = _page_with_text("Original", "helv", 16, color=(1, 1, 1),
+ bg=bg, at=(30, 60))
+ span = _first_span(page)
+ bbox = fitz.Rect(span["bbox"])
+ edit = _edit_from_span(span, "Modified")
+ _reinsert_edited_text(fitz, doc, page, edit)
+ pm = page.get_pixmap(dpi=150)
+ # sample a corner of the old span bbox (background, away from new glyphs)
+ px = int((bbox.x0 + 1) / page.rect.width * pm.width)
+ py = int((bbox.y0 + 1) / page.rect.height * pm.height)
+ r, g, b = pm.pixel(px, py)[:3]
+ doc.close()
+ # expected background ~ (216, 76, 76); a white box would be (255,255,255)
+ assert r > 190 and g < 130 and b < 130, (
+ "background not preserved (white-box artifact): got %s" % ((r, g, b),))
+
+
+def test_no_new_white_fill_drawn():
+ """The redaction must not add an opaque white vector fill over the span."""
+ doc, page = _page_with_text("Original", "helv", 14, bg=(0.2, 0.6, 0.9))
+ span = _first_span(page)
+ edit = _edit_from_span(span, "Edited")
+ _reinsert_edited_text(fitz, doc, page, edit)
+ # No filled path should be white (1,1,1) covering the region.
+ white_fills = [
+ d for d in page.get_drawings()
+ if d.get("fill") is not None
+ and tuple(round(c, 3) for c in d["fill"]) == (1.0, 1.0, 1.0)
+ ]
+ doc.close()
+ assert not white_fills, "an opaque white fill was drawn over the edit"
+
+
+def test_glyph_fallback_no_crash_and_renders():
+ """Editing in characters outside the base font uses Noto fallback, no tofu."""
+ doc, page = _page_with_text("Hello", "helv", 16)
+ _edit, _embedded, result = _edit_roundtrip(doc, page, "\u4e2d\u6587") # CJK
+ assert result is not None
+ assert result["text"] == "\u4e2d\u6587"
+
+
+def test_defensive_fallback_on_htmlbox_failure(monkeypatch):
+ """If insert_htmlbox raises, the base-14 insert_text path still writes a
+ valid, readable PDF instead of crashing or corrupting the page."""
+ doc, page = _page_with_text("Original", "helv", 15)
+ span = _first_span(page)
+ edit = _edit_from_span(span, "Fallback")
+
+ def _boom(*a, **k):
+ raise RuntimeError("forced htmlbox failure")
+
+ monkeypatch.setattr(fitz.Page, "insert_htmlbox", _boom)
+ embedded = _reinsert_edited_text(fitz, doc, page, edit)
+ assert embedded is False
+ data = doc.tobytes(garbage=4, deflate=True)
+ doc.close()
+ reopened = fitz.open("pdf", data)
+ result = _first_span(reopened[0])
+ txt = reopened[0].get_text()
+ reopened.close()
+ assert result is not None
+ assert "Fallback" in txt
+ assert result["size"] == pytest.approx(15.0, abs=0.6)
+
+
+def test_empty_new_text_only_redacts():
+ """Clearing a span removes it (no reinsertion, no crash)."""
+ doc, page = _page_with_text("DeleteMe", "helv", 14)
+ span = _first_span(page)
+ edit = _edit_from_span(span, " ") # whitespace -> treated as empty
+ embedded = _reinsert_edited_text(fitz, doc, page, edit)
+ txt = page.get_text().strip()
+ doc.close()
+ assert embedded is False
+ assert "DeleteMe" not in txt
+
+
+@pytest.mark.skipif(not os.path.exists(_DEJAVU),
+ reason="DejaVuSans.ttf not available on this platform")
+def test_embedded_font_reused_exactly():
+ """A fully embedded (non-subset) font is re-embedded, preserving the exact
+ typeface and size — the headline #147 fidelity win."""
+ doc = fitz.open()
+ page = doc.new_page(width=400, height=200)
+ page.insert_font(fontname="DVS", fontfile=_DEJAVU)
+ page.insert_text((30, 100), "Original", fontsize=16, fontname="DVS",
+ color=(0.1, 0.2, 0.7))
+ _edit, embedded, result = _edit_roundtrip(doc, page, "Modified")
+ assert embedded is True, "embedded font was not reused"
+ assert "DejaVu" in result["font"], (
+ "expected DejaVu family, got %r" % result["font"])
+ assert result["size"] == pytest.approx(16.0, abs=0.5)
+
+
+@pytest.mark.skipif(not os.path.exists(_DEJAVU),
+ reason="DejaVuSans.ttf not available on this platform")
+def test_subset_font_skipped_falls_back_to_generic():
+ """A subsetted font (ABCDEF+ prefix) is NOT re-embedded; the generic family
+ path is used instead (documented limitation)."""
+ doc = fitz.open()
+ page = doc.new_page(width=400, height=200)
+ page.insert_font(fontname="DVS", fontfile=_DEJAVU)
+ page.insert_text((30, 100), "Original", fontsize=16, fontname="DVS")
+ # Force subsetting: BaseFont gains an "ABCDEF+" prefix.
+ doc.subset_fonts()
+ data = doc.tobytes(garbage=4, deflate=True)
+ doc.close()
+ doc = fitz.open("pdf", data)
+ page = doc[0]
+ basefonts = [f[3] for f in page.get_fonts(full=False)]
+ assert any("+" in (bf or "") for bf in basefonts), (
+ "test setup failed: font not subsetted (%s)" % basefonts)
+ _edit, embedded, result = _edit_roundtrip(doc, page, "Modified")
+ assert embedded is False # subset skipped
+ assert result is not None
+ assert result["size"] == pytest.approx(16.0, abs=0.6)
+
+
+# ── A1: adjacent-line clipping (vertically tight redaction) ───────────────
+
+def test_redaction_rect_is_tight_body_band():
+ """The redaction band is ~one point size tall (NOT the ~1.35x inflated bbox
+ height) and sits inside the bbox, straddling the baseline (A1 geometry)."""
+ doc, page = _page_with_lines(["Only line"], "helv", 20)
+ span = _find_span(page, "Only")
+ edit = _edit_from_span(span, "x")
+ size = _text_edit_size(edit)
+ rr = _text_edit_redaction_rect(fitz, edit, size)
+ bbox = fitz.Rect(span["bbox"])
+ oy = span["origin"][1]
+ doc.close()
+ assert (rr.y1 - rr.y0) == pytest.approx(size, abs=0.5) # ~= body, not line
+ assert rr.height < bbox.height - 1.0 # tighter than bbox
+ # contained in the inflated bbox and straddling the baseline
+ assert bbox.y0 - 0.5 <= rr.y0 < oy < rr.y1 <= bbox.y1 + 0.5
+
+
+def test_redaction_rect_falls_back_to_bbox_when_metrics_missing():
+ """Degenerate/absent ascender-descender must fall back to the raw bbox,
+ which is always safe for removal (documented A1 fallback)."""
+ edit = {"bbox": [10, 20, 110, 40], "origin": [10, 38],
+ "ascender": 0, "descender": 0}
+ rr = _text_edit_redaction_rect(fitz, edit, 12)
+ assert [rr.x0, rr.y0, rr.x1, rr.y1] == [10, 20, 110, 40]
+
+
+def test_editing_middle_line_keeps_neighbours_intact():
+ """A1 (the reviewer's finding): editing the MIDDLE of three normally-leaded
+ lines must remove that line's old text yet leave the lines above and below
+ intact. FAILS with the old full-bbox redaction, which — because the bbox is
+ inflated by ascender/descender — erased the neighbours' glyphs too."""
+ doc, page = _page_with_lines(
+ ["AAA line one", "BBB middle row", "CCC line three"], "helv", 14)
+ span = _find_span(page, "BBB")
+ assert span is not None
+ edit = _edit_from_span(span, "EDITED middle")
+ _reinsert_edited_text(fitz, doc, page, edit)
+ data = doc.tobytes(garbage=4, deflate=True)
+ doc.close()
+ reopened = fitz.open("pdf", data)
+ txt = reopened[0].get_text()
+ reopened.close()
+ assert "EDITED middle" in txt # (i) new text present
+ assert "BBB middle row" not in txt # (ii) old target removed
+ assert "AAA line one" in txt # (iii) neighbour above intact
+ assert "CCC line three" in txt # (iii) neighbour below intact
+
+
+def test_editing_middle_line_neighbours_render_unclipped():
+ """A1 rendered check: the pixels of the neighbouring lines survive (a full
+ redaction would blank them). Confirms glyphs, not just the text stream."""
+ doc, page = _page_with_lines(
+ ["ABOVE row here", "TARGET middle", "BELOW row here"], "helv", 16,
+ at=(20, 60), width=300, height=200)
+ span = _find_span(page, "TARGET")
+ # ink coverage of the neighbour rows BEFORE the edit
+ def _row_ink(pg, y_center):
+ pm = pg.get_pixmap(dpi=150)
+ py = int(y_center / pg.rect.height * pm.height)
+ dark = 0
+ for px in range(pm.width):
+ r, g, b = pm.pixel(px, py)[:3]
+ if r < 128 and g < 128 and b < 128:
+ dark += 1
+ return dark
+ above_y = _find_span(page, "ABOVE")["origin"][1] - 5
+ below_y = _find_span(page, "BELOW")["origin"][1] - 5
+ ink_above_before = _row_ink(page, above_y)
+ ink_below_before = _row_ink(page, below_y)
+ edit = _edit_from_span(span, "REPLACED")
+ _reinsert_edited_text(fitz, doc, page, edit)
+ ink_above_after = _row_ink(page, above_y)
+ ink_below_after = _row_ink(page, below_y)
+ doc.close()
+ assert ink_above_before > 20 and ink_below_before > 20 # sanity
+ # neighbour ink must be essentially untouched (allow tiny AA jitter)
+ assert ink_above_after >= 0.9 * ink_above_before, "line above was clipped"
+ assert ink_below_after >= 0.9 * ink_below_before, "line below was clipped"
+
+
+# ── S1: no silent shrink to illegible ────────────────────────────────────
+
+def test_long_edit_stays_legible_not_silently_shrunk():
+ """S1: a long replacement must reflow at (near) its original size across
+ several lines instead of silently shrinking to an illegible size. FAILS with
+ the old short (3x size) box, which forced a large down-scale."""
+ doc, page = _page_with_text("Short", "helv", 14, width=300, height=360,
+ at=(30, 60))
+ span = _first_span(page)
+ long_text = ("This is a considerably longer replacement sentence that must "
+ "wrap onto several lines rather than shrink to a tiny illegible "
+ "size when the original editing box is too short to hold it at "
+ "its point size. It keeps going for a good while longer still.")
+ edit = _edit_from_span(span, long_text)
+ warned = []
+ _reinsert_edited_text(fitz, doc, page, edit, warn_fn=warned.append)
+ data = doc.tobytes(garbage=4, deflate=True)
+ doc.close()
+ reopened = fitz.open("pdf", data)
+ spans = [s for b in reopened[0].get_text("dict")["blocks"]
+ if b.get("type") == 0
+ for ln in b.get("lines", []) for s in ln.get("spans", [])]
+ full = reopened[0].get_text()
+ reopened.close()
+ assert spans, "text was lost"
+ min_size = min(s["size"] for s in spans)
+ assert min_size >= _MIN_LEGIBLE_SCALE * 14.0, (
+ "text shrank below the legibility floor: %.2fpt" % min_size)
+ assert "considerably longer replacement" in full # nothing dropped
+ assert not warned # fit at full size
+
+
+def test_unfittable_edit_warns_and_never_drops_text():
+ """S1: text that cannot fit even the full-height box must NOT be dropped
+ (scale_low stays 0 so htmlbox always draws) AND must raise the non-blocking
+ warning — never a silent illegible shrink."""
+ doc, page = _page_with_text("x", "helv", 14, width=120, height=90,
+ at=(20, 40))
+ span = _first_span(page)
+ huge = "word " * 400 # far more than a tiny page can hold at 14pt
+ edit = _edit_from_span(span, huge)
+ warned = []
+ _reinsert_edited_text(fitz, doc, page, edit, warn_fn=warned.append)
+ data = doc.tobytes(garbage=4, deflate=True)
+ doc.close()
+ reopened = fitz.open("pdf", data)
+ full = reopened[0].get_text()
+ reopened.close()
+ assert warned, "no downscale warning raised for un-fittable text"
+ assert "word" in full, "text was dropped instead of shrunk"