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"