Merge branch 'main' into claude/windows-custom-titlebar-75dde9

This commit is contained in:
ConnorYoh
2026-09-02 14:41:34 +01:00
committed by GitHub
349 changed files with 66268 additions and 9064 deletions
+3
View File
@@ -306,6 +306,9 @@ tasks.register('copyFrontendAssets', Copy) {
// Exclude files that conflict with backend static resources
exclude 'robots.txt' // Backend already has this
exclude 'favicon.ico' // Backend already has this
// Backend ships its own NotoSans-Regular.ttf here and it is git-tracked;
// letting the editor's copy win would dirty the source tree on every build.
exclude 'fonts/NotoSans-Regular.ttf'
}
into resourcesStaticDir
duplicatesStrategy = DuplicatesStrategy.INCLUDE // Let frontend overwrite when needed
@@ -0,0 +1,598 @@
package stirling.software.SPDF.controller.api;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.Operation;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
/**
* Charcode-encode helper for the v2 PDF text editor.
*
* <p>The frontend editor uses PDFium-WASM, which exposes {@code FPDFText_SetCharcodes} for writing
* new text using raw font charcodes (skipping PDFium's broken reverse Unicode→CID lookup for
* embedded subset fonts). What PDFium does NOT expose is the byte-encoding side of an existing font
* - given a PDFont and a Unicode string, what are the bytes the font's encoding produces? PDFBox
* does have that ({@link PDFont#encode}).
*
* <p>This endpoint accepts the source PDF + a "locator" describing where to find the font in
* question (page index + a sample char known to render in the target font, optionally narrowed by
* the font's /BaseFont name) + the Unicode text the frontend wants to encode. It returns the
* charcode sequence the frontend can pass to {@code FPDFText_SetCharcodes}.
*
* <p>If the locator can't find a matching text fragment, or if the font can't encode some chars,
* the response reports which chars are missing so the frontend can fall back to Helvetica per char.
*/
@Slf4j
@GeneralApi
@RequiredArgsConstructor
public class PdfTextEditorCharcodeController {
/** Reject JSON bodies whose base64 implies a decoded PDF larger than this. */
private static final int MAX_PDF_BYTES = 100 * 1024 * 1024;
/**
* Upper bound on {@code request.text} code units. Editor requests are word-sized; an unbounded
* text drove a per-code-point encode/exception loop (CPU burn) on crafted requests.
*/
private static final int MAX_TEXT_CHARS = 4096;
/** Nested form-XObject resource dictionaries visited per lookup (cycle/DoS guard). */
private static final int MAX_RESOURCE_DICTS = 32;
/** Bound on the reverse-map cache so a busy multi-document server can't grow it forever. */
private static final int REVERSE_MAP_CACHE_MAX = 32;
/** Access-ordered LRU bounded at {@link #REVERSE_MAP_CACHE_MAX} entries. */
private static final class BoundedReverseMapCache
extends java.util.LinkedHashMap<String, java.util.Map<String, Long>> {
private static final long serialVersionUID = 1L;
BoundedReverseMapCache() {
super(16, 0.75f, true);
}
@Override
protected boolean removeEldestEntry(
java.util.Map.Entry<String, java.util.Map<String, Long>> eldest) {
return size() > REVERSE_MAP_CACHE_MAX;
}
}
private static final java.util.Map<String, java.util.Map<String, Long>> REVERSE_MAP_CACHE =
java.util.Collections.synchronizedMap(new BoundedReverseMapCache());
private final CustomPDFDocumentFactory pdfDocumentFactory;
// NOTE: PDFBox's PDSimpleFont emits one "No Unicode mapping for .notdef" WARN per probed
// charcode when buildReverseUnicodeMap iterates 0..0xFFFF, which once flooded info.log to
// ~1.4 GB overnight. That logger is silenced DECLARATIVELY in logback.xml (a config entry ops
// can see and revert) rather than by mutating the global logger from a static block here -
// mutating it at class-load time hid the same warnings from every other tool in the JVM with
// no trace in configuration.
@Data
public static class EncodeCharcodesRequest {
/** Base64-encoded original PDF. The frontend already has the bytes loaded. */
private String pdfBase64;
/** 0-based page index containing the font sample. */
private int pageIndex;
/**
* A char known to exist on the page in the target font. Combined with {@code fontName}
* (when supplied) it locates the source PDFont via its ToUnicode CMap.
*/
private String locatorChar;
/**
* Optional /BaseFont name of the target font (as PDFium's FPDFFont_GetBaseFontName reports
* it). When a page has TWO fonts that both render {@code locatorChar}, this disambiguates
* which one to encode against - otherwise the first font found wins and a cross-font edit
* gets the wrong font's charcode. Null = keep the legacy first-match behaviour.
*/
private String fontName;
/**
* Optional SHA-256 (lowercase hex) of the target font's embedded program bytes (what
* PDFium's FPDFFont_GetFontData returns = the decoded FontFile/FontFile2/FontFile3 stream).
* This is the ONLY unambiguous font identity: PDFium strips the "ABCDEF+" subset tag from
* font names, so every subset of one family reports the same {@code fontName} and a
* name-based lookup can land on a SIBLING subset whose charcode space is different -
* returning valid-but-wrong charcodes that scramble the edited text. When present and a
* font on the page matches, it wins over name matching.
*/
private String fontSha256;
/** Unicode text the frontend wants to encode. */
private String text;
}
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class EncodeCharcodesResponse {
/**
* Per-char charcode array (one entry per code point in {@code request.text}). When the
* font's encoding produces multi-byte sequences, each char gets the full unsigned int value
* of its bytes packed big-endian (so a 2-byte CID like 0x004D becomes 77).
*/
private List<Long> charcodes;
/** Chars from the request that the font couldn't encode. */
private List<String> missing;
/** Diagnostic note - included so the frontend HUD can show what happened. */
private String note;
/** Set when the request failed entirely (bad pdf bytes, no matching font, etc.). */
private String error;
}
@Operation(
summary = "Encode Unicode → font charcodes for the v2 PDF text editor",
description =
"""
Frontend-only helper: takes the source PDF, a locator pointing at an existing
char rendered in the target font, and a Unicode string. Returns the byte
sequence the target font produces for that Unicode, packed as one unsigned
int per char. The frontend then calls FPDFText_SetCharcodes with the
returned ints to inject new text that reuses the embedded font's actual
glyphs. Chars the font can't encode are listed in `missing` so the caller
can fall back per-char.
""")
@PostMapping(
value = "/pdf-text-editor/encode-charcodes",
consumes = "application/json",
produces = "application/json")
public ResponseEntity<EncodeCharcodesResponse> encodeCharcodes(
@RequestBody EncodeCharcodesRequest request) {
EncodeCharcodesResponse resp = new EncodeCharcodesResponse();
if (request == null
|| request.getPdfBase64() == null
|| request.getText() == null
|| request.getLocatorChar() == null) {
resp.setError("missing required fields");
return ResponseEntity.badRequest().body(resp);
}
// length/4*3 bounds the decoded size without decoding, so we reject early before
// allocating.
String b64 = request.getPdfBase64();
if ((long) b64.length() / 4 * 3 > MAX_PDF_BYTES) {
resp.setError("pdf too large");
return ResponseEntity.status(413).body(resp);
}
// Reported separately: a combined check names only one cause and misleads the caller.
if (request.getText().length() > MAX_TEXT_CHARS) {
resp.setError("text too long");
return ResponseEntity.badRequest().body(resp);
}
if (request.getLocatorChar().length() > 4) {
resp.setError("locatorChar too long");
return ResponseEntity.badRequest().body(resp);
}
byte[] pdfBytes;
try {
pdfBytes = Base64.getDecoder().decode(b64);
} catch (IllegalArgumentException e) {
resp.setError("pdfBase64 is not valid base64");
return ResponseEntity.badRequest().body(resp);
}
try (PDDocument doc = pdfDocumentFactory.load(pdfBytes, true)) {
if (request.getPageIndex() < 0 || request.getPageIndex() >= doc.getNumberOfPages()) {
resp.setError("pageIndex out of range");
return ResponseEntity.badRequest().body(resp);
}
PDPage page = doc.getPage(request.getPageIndex());
// Skip walking the page's content stream (it crashes on Type3 fonts with
// UnsupportedOperationException("Not implemented: Type3") before we can do anything
// useful). Instead enumerate the page's font resources and pick the one identified by
// the request's font-program hash (definitive), falling back to name matching.
// For Chrome/Skia-printed PDFs that emit one Type3 font per glyph, this lands on
// the exact font that renders the locator char.
ResourceFont located =
findFontByToUnicode(
page,
request.getLocatorChar(),
request.getFontName(),
request.getFontSha256(),
doc);
if (located == null) {
resp.setError(
"no font on page "
+ request.getPageIndex()
+ " renders locatorChar="
+ request.getLocatorChar()
+ (request.getFontName() != null
? " (fontName=" + request.getFontName() + ")"
: ""));
return ResponseEntity.ok(resp);
}
// Build a reverse Unicode→charcode map by walking the font's ToUnicode CMap.
// This is the ONLY path that works for Type3 fonts (PDFBox's font.encode() throws
// "Not implemented: Type3" on them), and it also acts as a more reliable fallback
// for subset fonts whose encode() rejects chars not in the original document.
//
// For Sample.pdf specifically, every embedded font is Type3 (Chrome/Skia output),
// but they all carry a ToUnicode CMap mapping CIDs back to Unicode. We iterate
// charcodes 0..0xFFFF, call font.toUnicode(cc) for each, and record the inverse
// mapping for the chars the user wants to write.
PDFont font = located.font();
java.util.Map<String, Long> reverseMap =
buildReverseUnicodeMap(pdfBytes, located, request.getPageIndex());
List<Long> charcodes = new ArrayList<>();
List<String> missing = new ArrayList<>();
String text = request.getText();
int i = 0;
while (i < text.length()) {
int cp = text.codePointAt(i);
String oneChar = new String(Character.toChars(cp));
i += Character.charCount(cp);
// Whitespace is NEVER charcode-reused. Subset Type1/LaTeX fonts
// usually have no real space glyph, yet font.encode(0x20) still
// returns code 0x20 without throwing - and SetCharcodes(0x20)
// then paints whatever glyph sits at that subset code (e.g. „
// quotedblbase in LMRoman). Report whitespace as missing so the
// frontend emits it as a positional gap instead.
if (Character.isWhitespace(cp)) {
missing.add(oneChar);
continue;
}
// 1st try: font.encode() - works for Type0/TrueType/Type1
Long packed = null;
try {
byte[] encoded = font.encode(oneChar);
long p = 0L;
for (byte b : encoded) p = (p << 8) | (b & 0xff);
packed = p;
} catch (IOException
| IllegalArgumentException
| UnsupportedOperationException encodeEx) {
// 2nd try: ToUnicode reverse lookup - works for Type3 + anything with a CMap
packed = reverseMap.get(oneChar);
}
if (packed != null) charcodes.add(packed);
else missing.add(oneChar);
}
resp.setCharcodes(charcodes);
if (!missing.isEmpty()) resp.setMissing(missing);
resp.setNote(
"font="
+ font.getName()
+ " encoded "
+ charcodes.size()
+ " of "
+ (charcodes.size() + missing.size())
+ " chars");
return ResponseEntity.ok(resp);
} catch (IOException e) {
log.warn("encodeCharcodes: failed to load PDF", e);
resp.setError("failed to load PDF");
return ResponseEntity.badRequest().body(resp);
} catch (RuntimeException e) {
log.warn("encodeCharcodes: unexpected error", e);
resp.setError("unexpected error");
return ResponseEntity.status(500).body(resp);
}
}
/**
* Locate the font the request targets. Identity sources, strongest first:
*
* <ol>
* <li><b>Program hash</b>: SHA-256 of the embedded font program bytes. Definitive - two
* different subsets NEVER share program bytes, and PDFium's FPDFFont_GetFontData returns
* exactly the decoded FontFile stream, so frontend and backend hash the same bytes.
* <li><b>Exact /BaseFont name</b> (subset tag included), then <b>tag-stripped name</b>. Name
* matches are only accepted when UNAMBIGUOUS: PDFium reports subset fonts WITHOUT their
* "ABCDEF+" tag, so a page with several subsets of one family ("AAAAAC+Garamond",
* "AAAAAG+Garamond", ...) has them ALL match the stripped name - and encoding against the
* wrong sibling returns valid-but-wrong charcodes that scramble the edited text ("RUSSELL
* W. MANGUM" rendered "US EEL W. MANGS M"). With 2+ candidates we return null so the
* frontend takes its safe fallback instead of a coin flip.
* </ol>
*
* <p>This avoids running PDFStreamEngine.processPage, which throws
* UnsupportedOperationException on Type3 font glyph rendering. The PDFont lookup itself is
* purely metadata-driven and works on all subtypes.
*/
private static ResourceFont findFontByToUnicode(
PDPage page, String wantChar, String fontName, String fontSha256, PDDocument doc) {
try {
List<ResourceFont> fonts = collectResourceTreeFonts(page.getResources());
// 1) Program-hash identity. When several dicts share one program (identical bytes
// re-embedded), any of them renders the same glyphs for the same codes; prefer the
// one whose ToUnicode covers the locator char so the reverse map is usable.
if (fontSha256 != null && !fontSha256.isEmpty()) {
List<ResourceFont> hashMatches = new ArrayList<>();
for (ResourceFont rf : fonts) {
String sha = fontProgramSha256(rf.font());
if (fontSha256.equalsIgnoreCase(sha)) hashMatches.add(rf);
}
for (ResourceFont rf : hashMatches) {
if (probesToUnicode(rf.font(), wantChar)) return rf;
}
if (!hashMatches.isEmpty()) return hashMatches.get(0);
// No program on this page hashes to what the frontend is editing (e.g. PDFium
// returned a substitute font's bytes for a non-embedded font). Fall through to
// name matching rather than failing outright.
}
// 2) Name identity - exact tag-included first, then tag-stripped - each accepted
// only when it selects a single font.
if (fontName != null && !fontName.isEmpty()) {
ResourceFont exact =
selectUnambiguous(
fonts, wantChar, f -> fontName.equals(f.getName()), "exact");
if (exact != null) return exact;
String wantStripped = stripSubsetTag(fontName);
ResourceFont stripped =
selectUnambiguous(
fonts,
wantChar,
f -> wantStripped.equals(stripSubsetTag(f.getName())),
"stripped");
if (stripped != null) return stripped;
// The frontend NAMED the font it is editing. Falling back to "any font that
// renders the char" would hand back a DIFFERENT font's charcodes, which the
// frontend then writes into the named font's text object - wrong glyph, and the
// backend strategy skips all frontend validation. Report the char missing
// instead so the caller takes its own fallback path.
return null;
}
// 3) Legacy locator-only behaviour: first font whose ToUnicode renders the char.
for (ResourceFont rf : fonts) {
if (probesToUnicode(rf.font(), wantChar)) return rf;
}
} catch (RuntimeException ignore) {
// Be defensive: any single bad font shouldn't sink the whole request.
}
return null;
}
/**
* Apply {@code nameFilter}, then decide: exactly one candidate whose ToUnicode covers {@code
* wantChar} wins; two+ probe-hits are AMBIGUOUS (null). With zero probe-hits, a single
* name-matching font is still returned (font.encode() may handle chars without a ToUnicode -
* common for Type0/Identity-H), but two+ name matches are again ambiguous.
*/
private static ResourceFont selectUnambiguous(
List<ResourceFont> fonts,
String wantChar,
java.util.function.Predicate<PDFont> nameFilter,
String modeLabel) {
List<ResourceFont> named = new ArrayList<>();
for (ResourceFont rf : fonts) {
try {
if (rf.font().getName() != null && nameFilter.test(rf.font())) named.add(rf);
} catch (RuntimeException ignore) {
}
}
if (named.isEmpty()) return null;
List<ResourceFont> probed = new ArrayList<>();
for (ResourceFont rf : named) {
if (probesToUnicode(rf.font(), wantChar)) probed.add(rf);
}
if (probed.size() == 1) return probed.get(0);
if (probed.size() > 1) {
log.debug(
"encodeCharcodes: {} name match ambiguous ({} fonts render locator '{}') -"
+ " refusing cross-subset guess",
modeLabel,
probed.size(),
wantChar);
return null;
}
return named.size() == 1 ? named.get(0) : null;
}
/** True when some charcode in the font's ToUnicode CMap maps to {@code wantChar}. */
private static boolean probesToUnicode(PDFont font, String wantChar) {
// Cheap inverse-CMap probe: iterate codes until we hit one whose toUnicode is wantChar.
// For Type3 with at most ~16 glyphs, this is microseconds. For full Type0 subsets
// it's a few-thousand-iteration scan.
int upper = font.isStandard14() ? 256 : 0x10000;
for (int cc = 0; cc < upper; cc++) {
String u;
try {
u = font.toUnicode(cc);
} catch (Exception ignore) {
continue;
}
if (u != null && u.equals(wantChar)) return true;
}
return false;
}
private record ResourceFont(PDFont font, String path) {}
private record PendingResources(PDResources resources, String path) {}
/**
* Breadth-first collection of every distinct font reachable from the page's resources AND every
* nested form XObject's resources (bounded by {@link #MAX_RESOURCE_DICTS}, cycle-safe, deduped
* by COS dictionary identity). The v2 reader surfaces form-XObject text as editable, so its
* fonts must be findable too.
*/
private static List<ResourceFont> collectResourceTreeFonts(PDResources resources) {
List<ResourceFont> out = new ArrayList<>();
java.util.ArrayDeque<PendingResources> queue = new java.util.ArrayDeque<>();
java.util.Set<org.apache.pdfbox.cos.COSDictionary> seenDicts =
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
java.util.Set<org.apache.pdfbox.cos.COSDictionary> seenFonts =
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
if (resources != null) queue.add(new PendingResources(resources, ""));
int visited = 0;
// Bound a crafted page declaring many fonts none of which match (CPU-DoS guard).
final int MAX_FONTS = 64;
while (!queue.isEmpty() && visited < MAX_RESOURCE_DICTS) {
PendingResources pending = queue.poll();
PDResources res = pending.resources();
if (!seenDicts.add(res.getCOSObject())) continue;
visited++;
for (org.apache.pdfbox.cos.COSName name : res.getFontNames()) {
if (out.size() >= MAX_FONTS) break;
PDFont font;
try {
font = res.getFont(name);
} catch (IOException | RuntimeException e) {
continue;
}
if (font == null || !seenFonts.add(font.getCOSObject())) continue;
out.add(new ResourceFont(font, pending.path() + "/" + name.getName()));
}
try {
for (org.apache.pdfbox.cos.COSName xn : res.getXObjectNames()) {
try {
org.apache.pdfbox.pdmodel.graphics.PDXObject xo = res.getXObject(xn);
if (xo
instanceof
org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject form) {
PDResources fr = form.getResources();
if (fr != null) {
queue.add(
new PendingResources(
fr, pending.path() + "/" + xn.getName()));
}
}
} catch (IOException | RuntimeException ignore) {
}
}
} catch (RuntimeException ignore) {
}
}
return out;
}
/**
* SHA-256 (lowercase hex) of a font's embedded program bytes - the decoded
* FontFile/FontFile2/FontFile3 stream, which is byte-identical to what PDFium's
* FPDFFont_GetFontData hands the frontend. Null when the font embeds no program.
*/
private static String fontProgramSha256(PDFont font) {
try {
org.apache.pdfbox.pdmodel.font.PDFontDescriptor fd = font.getFontDescriptor();
if (fd == null && font instanceof org.apache.pdfbox.pdmodel.font.PDType0Font type0) {
fd = type0.getDescendantFont().getFontDescriptor();
}
if (fd == null) return null;
org.apache.pdfbox.pdmodel.common.PDStream stream = fd.getFontFile2();
if (stream == null) stream = fd.getFontFile3();
if (stream == null) stream = fd.getFontFile();
if (stream == null) return null;
return sha256Hex(stream.toByteArray());
} catch (IOException | RuntimeException e) {
return null;
}
}
/** Drop the 6-letter "ABCDEF+" subset prefix PDF puts on subset /BaseFont names. */
private static String stripSubsetTag(String fontName) {
if (fontName == null) return null;
if (fontName.length() > 7
&& fontName.charAt(6) == '+'
&& fontName.chars().limit(6).allMatch(c -> c >= 'A' && c <= 'Z')) {
return fontName.substring(7);
}
return fontName;
}
/**
* Build a Unicode→charcode map for a font by iterating every charcode in 0..0xFFFF and asking
* the font's ToUnicode CMap what Unicode it maps to. Charcodes that aren't in the CMap throw
* inside toUnicode (PDFBox returns null or throws depending on font subtype), and those are
* skipped silently.
*
* <p>This is the encoding inverse PDFBox doesn't expose directly. For Type3 fonts (where
* font.encode() throws "Not implemented"), this is the ONLY way to write text in the same font
* - we look up the user's char in the reverse map and pass that charcode to
* FPDFText_SetCharcodes on the frontend.
*
* <p>The 0..0xFFFF range is sufficient for Type0/CIDFontType2 fonts (CIDs are 16-bit). For
* single-byte fonts the loop short-circuits after 256. We don't go higher because no PDF font
* has a CID outside that range in practice; the per-font result is memoised in {@link
* #REVERSE_MAP_CACHE} so the 65 536-entry probe runs once per document+font, not per request.
*/
private static java.util.Map<String, Long> buildReverseUnicodeMap(
byte[] pdfBytes, ResourceFont located, int pageIndex) {
String key = sha256Hex(pdfBytes) + "|" + fontCacheIdentity(located, pageIndex);
// Compound get/put under the map's own monitor. The 0..0xFFFF probe runs OUTSIDE the
// lock so one slow build can't block every other request on the shared cache.
java.util.Map<String, Long> cached;
synchronized (REVERSE_MAP_CACHE) {
cached = REVERSE_MAP_CACHE.get(key);
}
if (cached != null) return cached;
java.util.Map<String, Long> built = computeReverseUnicodeMap(located.font());
synchronized (REVERSE_MAP_CACHE) {
java.util.Map<String, Long> raced = REVERSE_MAP_CACHE.putIfAbsent(key, built);
return raced != null ? raced : built;
}
}
private static String fontCacheIdentity(ResourceFont located, int pageIndex) {
org.apache.pdfbox.cos.COSObjectKey objectKey = null;
try {
objectKey = located.font().getCOSObject().getKey();
} catch (RuntimeException ignore) {
}
if (objectKey != null) {
return "obj|" + objectKey.getNumber() + "." + objectKey.getGeneration();
}
return "res|p" + pageIndex + located.path();
}
/** Lowercase hex SHA-256 of the PDF bytes; used as the reverse-map cache key. */
private static String sha256Hex(byte[] bytes) {
try {
byte[] digest = java.security.MessageDigest.getInstance("SHA-256").digest(bytes);
StringBuilder sb = new StringBuilder(digest.length * 2);
for (byte b : digest) {
sb.append(Character.forDigit((b >> 4) & 0xf, 16));
sb.append(Character.forDigit(b & 0xf, 16));
}
return sb.toString();
} catch (java.security.NoSuchAlgorithmException e) {
// SHA-256 is always present in a JRE; fall back to a length+hash key just in case so
// the cache still functions (correctness holds - collisions only cost a rebuild).
return bytes.length + ":" + java.util.Arrays.hashCode(bytes);
}
}
private static java.util.Map<String, Long> computeReverseUnicodeMap(PDFont font) {
java.util.Map<String, Long> out = new java.util.HashMap<>();
int upper = font.isStandard14() ? 256 : 0x10000;
for (int cc = 0; cc < upper; cc++) {
String u;
try {
u = font.toUnicode(cc);
} catch (Exception ignore) {
continue;
}
if (u == null || u.isEmpty()) continue;
// First charcode wins for a given Unicode (the canonical mapping).
out.putIfAbsent(u, (long) cc);
}
return out;
}
}
@@ -154,7 +154,8 @@ public class PdfJsonFontService {
return "otf";
}
if (signature == 0x74746366) {
return "cff";
log.debug("[FONT-DEBUG] TrueType Collection ('ttcf') font program is unsupported");
return null;
}
return null;
}
@@ -175,7 +176,8 @@ public class PdfJsonFontService {
return "otf";
}
if (signature == 0x74746366) {
return "cff";
log.debug("[FONT-DEBUG] TrueType Collection ('ttcf') FontFile2 is unsupported");
return null;
}
return null;
}
+42 -5
View File
@@ -15,26 +15,63 @@
<encoder>
<pattern>%d %p %c{1} [%thread] %m%n</pattern>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/auth-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
<!-- SizeAndTime, not Time alone: the size trigger is what stops a
runaway logger filling the disk (see GENERAL appender note).
Archives are gzipped, so 64 MB of them holds far more than a
day. Worst case on disk is one 100 MB live file plus the cap. -->
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/auth-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>7</maxHistory>
<totalSizeCap>64MB</totalSizeCap>
</rollingPolicy>
</appender>
<!-- Rolling File Appender for General Logs -->
<!-- Rolling File Appender for General Logs
Why SizeAndTimeBased + totalSizeCap: a previous build of the v2 PDF
text editor's reverse-CMap probe loop triggered PDSimpleFont to emit
one "No Unicode mapping for .notdef" WARN per probed charcode per
font per request. With TimeBasedRollingPolicy alone there was no
size ceiling; info.log grew to 1.4 GB in a single day before the JVM
choked. The class-level silencer fixes the specific offender, but
this size cap is the defence-in-depth: any future logger that
floods unexpectedly will roll + auto-delete instead of starving
disk + Jetty threads. -->
<appender name="GENERAL" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/info.log</file>
<encoder>
<pattern>%d %p %c{1} [%thread] %m%n</pattern>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/info-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/info-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>7</maxHistory>
<totalSizeCap>256MB</totalSizeCap>
</rollingPolicy>
</appender>
<!-- Suppress PDFBox PDSimpleFont's per-charcode .notdef WARN.
Required by the v2 PDF text editor's `buildReverseUnicodeMap`
which DELIBERATELY iterates every charcode in 0..0xFFFF to
discover the encoding-to-Unicode map of an embedded subset
font. For any subset font ~99% of those probes hit .notdef,
and the default WARN level for those misses turned info.log
into a 1.4 GB monster overnight.
This declarative logback entry is the SOLE mechanism: it is
visible to ops and revertable via configuration. An earlier
build also mutated this logger's level from a static block in
PdfTextEditorCharcodeController, which silenced the same
warnings JVM-wide with no trace in any config file - that
static block has been removed in favour of this entry. -->
<logger name="org.apache.pdfbox.pdmodel.font.PDSimpleFont"
level="ERROR" additivity="false">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="GENERAL"/>
</logger>
<!-- Root Logger -->
<root level="INFO">
<appender-ref ref="CONSOLE"/>
@@ -57,6 +57,8 @@ class ToolIODeclarationCoverageTest {
// documents.
"/api/v1/convert/pdf/text-editor",
"/api/v1/convert/text-editor/pdf",
// Charcode lookup for the v2 editor: returns glyph mappings, not a document.
"/api/v1/general/pdf-text-editor",
// Signing sessions, certificate checks and hardware token enumeration; the
// signing tool itself is /api/v1/security/cert-sign, which is declared.
"/api/v1/security/cert-sign/sessions",
@@ -0,0 +1,516 @@
package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import javax.imageio.ImageIO;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDFontDescriptor;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.PDType3Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
/**
* Probe: what can PDFBox actually do for font ENCODING on real-world PDFs. This is a diagnostic
* test (not a regression) - run with --tests PdfBoxFontEncodingProbeTest -i to see stdout.
*
* <p>Answers these questions:
*
* <ol>
* <li>Type0/CIDFontType2 subset: can we add a new glyph not in the original subset? (no, encode
* throws IllegalArgumentException).
* <li>Type1: same question.
* <li>TrueType: same question.
* <li>Can we load a fresh TTF via PDType0Font.load(doc, file) and write text with it? (yes,
* primary path).
* <li>Round-trip via getFontStream / re-embed - can it rehabilitate Type3? (no - Type3 has no
* FontFile* program at all).
* <li>What fonts ship with PDFBox / fontbox? (only LiberationSans-Regular.ttf + AFM for the 14
* standard fonts; CFF/Type1 binaries are NOT bundled - Standard14Fonts.getMappedFontName
* redirects unmappable ones to LiberationSans).
* </ol>
*/
@Disabled(
"Diagnostic probe: dumps PDFBox font encoding tables to stdout and asserts nothing. Kept for font debugging; run manually.")
public class PdfBoxFontEncodingProbeTest {
private static final Path PROJECT_ROOT =
Paths.get(System.getProperty("user.dir")).getParent().getParent();
private static final Path SAMPLE =
PROJECT_ROOT.resolve("frontend/editor/public/samples/Sample.pdf");
private static final Path[] EXTRA_FIXTURES = {
PROJECT_ROOT.resolve("frontend/editor/src/core/tests/test-fixtures/stirling-marketing.pdf"),
PROJECT_ROOT.resolve("frontend/editor/src/core/tests/test-fixtures/multi-page-sample.pdf"),
PROJECT_ROOT.resolve("frontend/editor/src/core/tests/test-fixtures/big-sample.pdf"),
PROJECT_ROOT.resolve("frontend/editor/src/core/tests/test-fixtures/paragraph-sample.pdf"),
PROJECT_ROOT.resolve("frontend/editor/src/core/tests/test-fixtures/user-sample.pdf"),
};
/**
* Rasterize the Q4b output (Sample.pdf with injected Liberation text) to confirm the new text
* actually renders on top of the existing Type3 content.
*/
@Test
public void probeRenderInjectedSample() throws IOException {
Path liberation =
PROJECT_ROOT.resolve(
"app/core/src/main/resources/static/fonts/LiberationSans-Regular.ttf");
byte[] pdfBytes = Files.readAllBytes(SAMPLE);
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
PDPage page = doc.getPage(0);
PDType0Font ttf;
try (InputStream in = Files.newInputStream(liberation)) {
ttf = PDType0Font.load(doc, in, true);
}
try (PDPageContentStream cs =
new PDPageContentStream(
doc, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
cs.beginText();
cs.setFont(ttf, 24);
cs.newLineAtOffset(50, 120);
cs.showText("INJECTED via PDType0Font.load - $@#&Z");
cs.endText();
}
doc.save(out);
}
// Rasterize page 0 to a PNG so we can eyeball it.
try (PDDocument check = Loader.loadPDF(out.toByteArray())) {
PDFRenderer renderer = new PDFRenderer(check);
java.awt.image.BufferedImage img = renderer.renderImageWithDPI(0, 100);
// Build dir, not the repo root: this render is a debugging aid and was
// twice committed by accident when it landed in the working tree.
Path png =
Paths.get(System.getProperty("user.dir"), "build", "probe-output")
.resolve("pdfbox-probe-q4b-rendered.png");
Files.createDirectories(png.getParent());
ImageIO.write(img, "PNG", png.toFile());
System.out.println(
"Rendered injected sample to "
+ png
+ " - "
+ img.getWidth()
+ "x"
+ img.getHeight());
}
}
/**
* Build a PDF in memory that uses a Type0/CIDFontType2 subset font (the kind Word / InDesign /
* LibreOffice produce), then probe whether encode() can add a glyph that wasn't in the original
* subset.
*/
@Test
public void probeType0CIDFontType2Subset() throws IOException {
System.out.println(
"\n##################################################################\n"
+ "Q1 probe: Type0/CIDFontType2 SUBSET can/cannot add new glyphs\n"
+ "##################################################################\n");
Path liberation =
PROJECT_ROOT.resolve(
"app/core/src/main/resources/static/fonts/LiberationSans-Regular.ttf");
// Build a PDF that contains only "abc" subsetted from LiberationSans.
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
PDType0Font subset;
try (InputStream in = Files.newInputStream(liberation)) {
subset = PDType0Font.load(doc, in, true /* embedSubset */);
}
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.beginText();
cs.setFont(subset, 12);
cs.newLineAtOffset(100, 700);
cs.showText("abc");
cs.endText();
}
doc.save(baos);
}
// Reload the produced PDF and try to add a NEW glyph through the embedded subset font.
byte[] subsetPdf = baos.toByteArray();
try (PDDocument doc = Loader.loadPDF(subsetPdf)) {
PDResources res = doc.getPage(0).getResources();
for (COSName fn : res.getFontNames()) {
PDFont f = res.getFont(fn);
System.out.println(
" Subset font in saved PDF: "
+ f.getName()
+ " ("
+ f.getClass().getSimpleName()
+ ", subType="
+ f.getSubType()
+ ")");
for (String ch : new String[] {"a", "b", "c", "Z", "z", "0", "$", "@", "X", " "}) {
try {
byte[] enc = f.encode(ch);
StringBuilder hex = new StringBuilder();
for (byte b : enc) hex.append(String.format("%02X ", b & 0xff));
System.out.println(
" encode('" + ch + "') -> [" + hex.toString().trim() + "] OK");
} catch (UnsupportedOperationException uoe) {
System.out.println(" encode('" + ch + "') UNSUPPORTED");
} catch (IllegalArgumentException iae) {
System.out.println(
" encode('" + ch + "') MISSING - " + iae.getMessage());
} catch (IOException ioe) {
System.out.println(" encode('" + ch + "') IO ERR - " + ioe.getMessage());
}
}
}
}
}
@Test
public void probeExtraFixtures() throws IOException {
System.out.println(
"\n##################################################################\n"
+ "Extra fixture font-class probe\n"
+ "##################################################################\n");
for (Path fixture : EXTRA_FIXTURES) {
if (!Files.exists(fixture)) {
System.out.println("(missing) " + fixture);
continue;
}
System.out.println("\n=== " + fixture.getFileName() + " ===");
byte[] bytes = Files.readAllBytes(fixture);
try (PDDocument doc = Loader.loadPDF(bytes)) {
Set<COSName> seen = new HashSet<>();
for (int p = 0; p < doc.getNumberOfPages(); p++) {
PDPage page = doc.getPage(p);
PDResources res = page.getResources();
if (res == null) continue;
for (COSName name : res.getFontNames()) {
if (!seen.add(name)) continue;
try {
PDFont f = res.getFont(name);
if (f == null) continue;
String fontFile = "none";
PDFontDescriptor d = f.getFontDescriptor();
if (d != null) {
if (d.getFontFile() != null) fontFile = "FontFile";
else if (d.getFontFile2() != null) fontFile = "FontFile2";
else if (d.getFontFile3() != null) fontFile = "FontFile3";
}
String z = "?";
try {
f.encode("Z");
z = "OK";
} catch (UnsupportedOperationException ex) {
z = "UNSUPPORTED";
} catch (IllegalArgumentException ex) {
z = "MISSING";
} catch (IOException ex) {
z = "IO_ERR";
}
System.out.println(
" page "
+ p
+ " "
+ name.getName()
+ " -> "
+ f.getName()
+ " "
+ f.getClass().getSimpleName()
+ " ("
+ f.getSubType()
+ ", "
+ fontFile
+ ", embed="
+ f.isEmbedded()
+ ") encode('Z')="
+ z);
} catch (IOException e) {
System.out.println(
" page "
+ p
+ " "
+ name.getName()
+ " load failed: "
+ e.getMessage());
}
}
}
}
}
}
@Test
public void probeAllQuestions() throws IOException {
System.out.println(
"\n##################################################################\n"
+ "PDFBox font-encoding probe (Sample.pdf + bundled fallback fonts)\n"
+ "##################################################################\n");
// Discover every font in Sample.pdf so we have a real-world test set.
byte[] pdfBytes = Files.readAllBytes(SAMPLE);
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
List<PDFont> allFonts = new ArrayList<>();
Set<COSName> seen = new HashSet<>();
for (int p = 0; p < doc.getNumberOfPages(); p++) {
PDPage page = doc.getPage(p);
PDResources res = page.getResources();
if (res == null) continue;
for (COSName name : res.getFontNames()) {
if (!seen.add(name)) continue;
try {
PDFont f = res.getFont(name);
if (f != null) allFonts.add(f);
} catch (Exception e) {
System.out.println(
" (skipped " + name.getName() + " - " + e.getMessage() + ")");
}
}
}
System.out.println(
"Discovered " + allFonts.size() + " unique fonts across Sample.pdf:");
for (PDFont f : allFonts) {
System.out.println(
" - "
+ f.getName()
+ " ("
+ f.getClass().getSimpleName()
+ ", subType="
+ f.getSubType()
+ ", embedded="
+ f.isEmbedded()
+ ")");
}
// Q1/Q2/Q3
// Try encoding a char that is NEVER in Sample.pdf via each font.
// 'Z' is unlikely to be in the subset for most marketing pages.
// Try several candidates to surface what each font can/can't add.
String[] candidates = {"Z", "$", "@", "#", "Q", "&", "A", "0", "M"};
for (PDFont f : allFonts) {
System.out.println("\n=== Encode-probe for font: " + f.getName() + " ===");
for (String ch : candidates) {
try {
byte[] enc = f.encode(ch);
StringBuilder hex = new StringBuilder();
for (byte b : enc) hex.append(String.format("%02X ", b & 0xff));
System.out.println(
" encode('" + ch + "') -> [" + hex.toString().trim() + "] OK");
} catch (UnsupportedOperationException uoe) {
System.out.println(
" encode('" + ch + "') UNSUPPORTED: " + uoe.getMessage());
} catch (IllegalArgumentException iae) {
System.out.println(" encode('" + ch + "') MISSING: " + iae.getMessage());
} catch (IOException ioe) {
System.out.println(" encode('" + ch + "') IO ERR: " + ioe.getMessage());
}
}
}
// Q5
// For each font, see what's in the FontFile* stream - this is what we'd
// have to round-trip through to "rehabilitate" a Type3 font.
System.out.println("\n=== FontFile stream availability (Q5) ===");
for (PDFont f : allFonts) {
String kind = "none";
int size = 0;
PDFontDescriptor d = f.getFontDescriptor();
if (d != null) {
if (d.getFontFile() != null) {
kind = "FontFile (Type1)";
size = streamBytes(d.getFontFile().getCOSObject().createInputStream());
} else if (d.getFontFile2() != null) {
kind = "FontFile2 (TTF)";
size = streamBytes(d.getFontFile2().getCOSObject().createInputStream());
} else if (d.getFontFile3() != null) {
kind = "FontFile3 (CFF/OpenType)";
size = streamBytes(d.getFontFile3().getCOSObject().createInputStream());
}
}
System.out.println(
" "
+ f.getName()
+ " ("
+ f.getClass().getSimpleName()
+ "): "
+ kind
+ " ("
+ size
+ " bytes)");
if (f instanceof PDType3Font) {
System.out.println(
" -> Type3 has CharProc streams, NOT a FontFile binary."
+ " getFontStream() returns null. Round-trip rehab is impossible:");
System.out.println(
" each glyph is a mini content stream, not a glyph outline in a"
+ " standard font format. We'd need to rasterize each CharProc to"
+ " glyph outlines + build a fresh TTF/CFF from scratch.");
}
}
}
// Q4: PDType0Font.load(doc, file) round-trip
System.out.println("\n=== Q4: load fresh TTF and write text to a fresh PDF ===");
Path liberation =
PROJECT_ROOT.resolve(
"app/core/src/main/resources/static/fonts/LiberationSans-Regular.ttf");
if (!Files.exists(liberation)) {
System.out.println(" Liberation TTF not found at " + liberation);
} else {
try (PDDocument out = new PDDocument()) {
PDPage page = new PDPage();
out.addPage(page);
PDType0Font ttf;
try (InputStream in = Files.newInputStream(liberation)) {
ttf = PDType0Font.load(out, in, true /* embedSubset */);
}
System.out.println(
" Loaded TTF -> "
+ ttf.getName()
+ " ("
+ ttf.getClass().getSimpleName()
+ ")");
String testText = "Hello world! 0123 Z $ @";
byte[] encoded = ttf.encode(testText);
System.out.println(
" Encoded "
+ testText.length()
+ " chars -> "
+ encoded.length
+ " bytes (Identity-H = 2 bytes/glyph)");
try (PDPageContentStream cs = new PDPageContentStream(out, page)) {
cs.beginText();
cs.setFont(ttf, 12);
cs.newLineAtOffset(100, 700);
cs.showText(testText);
cs.endText();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
out.save(baos);
Path tmp = Files.createTempFile("pdfbox-probe-q4-", ".pdf");
Files.write(tmp, baos.toByteArray());
System.out.println(
" Wrote fresh-TTF PDF to "
+ tmp
+ " ("
+ baos.size()
+ " bytes) - opens cleanly.");
// Re-load to confirm the new font is embedded properly.
try (PDDocument check = Loader.loadPDF(baos.toByteArray())) {
PDResources res = check.getPage(0).getResources();
for (COSName fn : res.getFontNames()) {
PDFont f = res.getFont(fn);
System.out.println(
" embedded font: "
+ f.getName()
+ " ("
+ f.getClass().getSimpleName()
+ ", embedded="
+ f.isEmbedded()
+ ")");
}
}
}
}
// Q4b: load TTF into an EXISTING PDF (Sample.pdf) and append text
System.out.println(
"\n=== Q4b: load TTF into EXISTING Sample.pdf and write text on page 0 ===");
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
PDPage page = doc.getPage(0);
PDType0Font ttf;
try (InputStream in = Files.newInputStream(liberation)) {
ttf = PDType0Font.load(doc, in, true);
}
// append-mode content stream so we don't disturb existing graphics
try (PDPageContentStream cs =
new PDPageContentStream(
doc,
page,
PDPageContentStream.AppendMode.APPEND,
true /* compress */,
true /* resetContext */)) {
cs.beginText();
cs.setFont(ttf, 12);
cs.newLineAtOffset(50, 50);
cs.showText("Injected via PDType0Font.load - $@#&");
cs.endText();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
Path tmp = Files.createTempFile("pdfbox-probe-q4b-", ".pdf");
Files.write(tmp, baos.toByteArray());
System.out.println(
" Wrote injected-text PDF to " + tmp + " (" + baos.size() + " bytes).");
// Verify by re-reading: how many fonts now on page 0?
try (PDDocument check = Loader.loadPDF(baos.toByteArray())) {
PDResources res = check.getPage(0).getResources();
int count = 0;
for (COSName fn : res.getFontNames()) {
PDFont f = res.getFont(fn);
count++;
System.out.println(
" page-0 font: "
+ fn.getName()
+ " -> "
+ f.getName()
+ " ("
+ f.getClass().getSimpleName()
+ ")");
}
System.out.println(" Total fonts on page 0: " + count);
}
}
// Q6: what fonts ship in PDFBox / fontbox
System.out.println("\n=== Q6: bundled fonts (Standard14 redirect probe) ===");
for (Standard14Fonts.FontName fn : Standard14Fonts.FontName.values()) {
PDType1Font f = new PDType1Font(fn);
String mapped = "" + Standard14Fonts.getMappedFontName(fn.getName());
System.out.println(
" Standard14 "
+ fn.getName()
+ " -> mapped='"
+ mapped
+ "' name="
+ f.getName());
}
System.out.println(
" (PDFBox bundles ONLY LiberationSans-Regular.ttf as a binary; the AFMs cover"
+ " metrics for the 14 standard fonts but rendering Helvetica/Times/Courier"
+ " glyphs falls back to LiberationSans glyphs at runtime when no system font"
+ " is found.)");
}
private static int streamBytes(InputStream is) {
try (InputStream it = is) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buf = new byte[4096];
int n;
while ((n = it.read(buf)) >= 0) baos.write(buf, 0, n);
return baos.size();
} catch (IOException e) {
return -1;
}
}
}
@@ -0,0 +1,755 @@
package stirling.software.SPDF.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.util.Base64;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.junit.jupiter.api.Test;
import org.springframework.http.ResponseEntity;
import stirling.software.SPDF.controller.api.PdfTextEditorCharcodeController.EncodeCharcodesRequest;
import stirling.software.SPDF.controller.api.PdfTextEditorCharcodeController.EncodeCharcodesResponse;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
/**
* Regression coverage for the v2 text editor "spaces render as „" bug.
*
* <p>mushroom-life.pdf is a LaTeX document whose embedded LMRoman subset font has NO real space
* glyph, yet {@code font.encode(" ")} still returns charcode 0x20 without throwing. Reusing that
* code via {@code FPDFText_SetCharcodes} paints whatever glyph sits at subset code 0x20 - the
* quotedblbase „. The controller must therefore report whitespace as {@code missing} so the
* frontend emits it as a positional gap instead of a reused glyph.
*/
class PdfTextEditorCharcodeControllerTest {
private static PdfTextEditorCharcodeController controller() {
return new PdfTextEditorCharcodeController(
new CustomPDFDocumentFactory(mock(PdfMetadataService.class)));
}
private static String mushroomBase64() throws Exception {
try (InputStream in =
PdfTextEditorCharcodeControllerTest.class.getResourceAsStream(
"/pdftexteditor/mushroom-life.pdf")) {
assertThat(in).as("mushroom-life.pdf test resource").isNotNull();
return Base64.getEncoder().encodeToString(in.readAllBytes());
}
}
private static EncodeCharcodesRequest request(String text) throws Exception {
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
req.setPdfBase64(mushroomBase64());
req.setPageIndex(0);
// findFontByToUnicode locates the font via the ToUnicode CMap - "M" exists on page 0.
req.setLocatorChar("M");
req.setText(text);
return req;
}
@Test
void spaceIsReportedMissingNeverEncoded() throws Exception {
PdfTextEditorCharcodeController controller = controller();
ResponseEntity<EncodeCharcodesResponse> resp = controller.encodeCharcodes(request(" "));
EncodeCharcodesResponse body = resp.getBody();
assertThat(body).isNotNull();
assertThat(body.getError()).isNull();
// The space must be reported missing, NOT handed back as a charcode
// (0x20) the frontend would reuse into the „ glyph.
assertThat(body.getMissing()).containsExactly(" ");
assertThat(body.getCharcodes()).isNullOrEmpty();
}
@Test
void realCharsEncodeWhileWhitespaceStaysAGap() throws Exception {
PdfTextEditorCharcodeController controller = controller();
// "M M" - both M's must encode to real charcodes; only the space is a gap.
ResponseEntity<EncodeCharcodesResponse> resp = controller.encodeCharcodes(request("M M"));
EncodeCharcodesResponse body = resp.getBody();
assertThat(body).isNotNull();
assertThat(body.getError()).isNull();
assertThat(body.getCharcodes()).as("both M glyphs encode").hasSize(2);
assertThat(body.getMissing()).containsExactly(" ");
}
@Test
void tabAndNewlineAreAlsoTreatedAsGaps() throws Exception {
PdfTextEditorCharcodeController controller = controller();
ResponseEntity<EncodeCharcodesResponse> resp = controller.encodeCharcodes(request("\t\n"));
EncodeCharcodesResponse body = resp.getBody();
assertThat(body).isNotNull();
assertThat(body.getMissing()).containsExactly("\t", "\n");
assertThat(body.getCharcodes()).isNullOrEmpty();
}
/**
* A page with two fonts that BOTH render 'A'. {@code fontName} must select which one to encode
* against - the cross-font fix. Without it the first font in resources order won wins and a
* cross-font edit got the wrong font's charcode.
*/
private static String twoFontBase64() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
PDType1Font helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
PDType1Font times = new PDType1Font(Standard14Fonts.FontName.TIMES_ROMAN);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.beginText();
cs.setFont(helvetica, 12);
cs.newLineAtOffset(72, 720);
cs.showText("A");
cs.endText();
cs.beginText();
cs.setFont(times, 12);
cs.newLineAtOffset(72, 700);
cs.showText("A");
cs.endText();
}
ByteArrayOutputStream bos = new ByteArrayOutputStream();
doc.save(bos);
return Base64.getEncoder().encodeToString(bos.toByteArray());
}
}
private static EncodeCharcodesRequest twoFontRequest(String fontName) throws Exception {
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
req.setPdfBase64(twoFontBase64());
req.setPageIndex(0);
req.setLocatorChar("A");
req.setFontName(fontName);
req.setText("A");
return req;
}
@Test
void fontNameDisambiguatesBetweenTwoFontsRenderingTheSameChar() throws Exception {
PdfTextEditorCharcodeController controller = controller();
// Targeting Times-Roman must encode against Times-Roman, not whichever
// font happens to appear first in the page's font resources.
EncodeCharcodesResponse times =
controller.encodeCharcodes(twoFontRequest("Times-Roman")).getBody();
assertThat(times).isNotNull();
assertThat(times.getError()).isNull();
assertThat(times.getNote()).contains("Times-Roman");
assertThat(times.getCharcodes()).hasSize(1);
// Targeting Helvetica must encode against Helvetica.
EncodeCharcodesResponse helv =
controller.encodeCharcodes(twoFontRequest("Helvetica")).getBody();
assertThat(helv).isNotNull();
assertThat(helv.getError()).isNull();
assertThat(helv.getNote()).contains("Helvetica");
assertThat(helv.getCharcodes()).hasSize(1);
}
@Test
void unknownFontNameReportsNoFontInsteadOfWrongFont() throws Exception {
PdfTextEditorCharcodeController controller = controller();
// A name that matches no font on the page must NOT silently encode
// against a different font: the frontend writes the returned charcodes
// into the NAMED font's text object, so a first-match fallback would
// bake wrong glyphs. It must report failure so the caller falls back.
EncodeCharcodesResponse body =
controller.encodeCharcodes(twoFontRequest("DoesNotExist")).getBody();
assertThat(body).isNotNull();
assertThat(body.getError()).contains("no font");
assertThat(body.getCharcodes()).isNull();
}
@Test
void missingRequiredFieldsReturns400() {
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
req.setPdfBase64("AAAA");
req.setLocatorChar("M");
// text is null
ResponseEntity<EncodeCharcodesResponse> resp = controller().encodeCharcodes(req);
assertThat(resp.getStatusCode().value()).isEqualTo(400);
assertThat(resp.getBody()).isNotNull();
assertThat(resp.getBody().getError()).isEqualTo("missing required fields");
}
@Test
void invalidBase64Returns400() {
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
req.setPdfBase64("!!!notbase64!!!");
req.setLocatorChar("M");
req.setText("M");
ResponseEntity<EncodeCharcodesResponse> resp = controller().encodeCharcodes(req);
assertThat(resp.getStatusCode().value()).isEqualTo(400);
assertThat(resp.getBody()).isNotNull();
assertThat(resp.getBody().getError()).isEqualTo("pdfBase64 is not valid base64");
}
@Test
void pageIndexOutOfRangeReturns400() throws Exception {
EncodeCharcodesRequest req = request("M");
req.setPageIndex(999);
ResponseEntity<EncodeCharcodesResponse> resp = controller().encodeCharcodes(req);
assertThat(resp.getStatusCode().value()).isEqualTo(400);
assertThat(resp.getBody()).isNotNull();
assertThat(resp.getBody().getError()).isEqualTo("pageIndex out of range");
}
@Test
void nonPdfBytesReturnsGenericError() {
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
req.setPdfBase64(Base64.getEncoder().encodeToString("not a pdf".getBytes()));
req.setLocatorChar("M");
req.setText("M");
// Must not throw, and must not leak the raw PDFBox parser message.
ResponseEntity<EncodeCharcodesResponse> resp = controller().encodeCharcodes(req);
assertThat(resp.getStatusCode().is4xxClientError()).isTrue();
assertThat(resp.getBody()).isNotNull();
assertThat(resp.getBody().getError()).isEqualTo("failed to load PDF");
}
@Test
void absentLocatorCharReturns200WithError() throws Exception {
// U+FFFF never appears in the document, so no font matches.
ResponseEntity<EncodeCharcodesResponse> resp =
controller().encodeCharcodes(requestWithLocator("￿"));
assertThat(resp.getStatusCode().value()).isEqualTo(200);
EncodeCharcodesResponse body = resp.getBody();
assertThat(body).isNotNull();
assertThat(body.getError()).isNotNull();
assertThat(body.getCharcodes()).isNull();
}
@Test
void oversizePdfRejected() {
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
// A base64 string long enough that length/4*3 exceeds the 100MB cap, without
// ever allocating the decoded bytes (the guard runs before decode).
char[] huge = new char[140 * 1024 * 1024];
java.util.Arrays.fill(huge, 'A');
req.setPdfBase64(new String(huge));
req.setLocatorChar("M");
req.setText("M");
ResponseEntity<EncodeCharcodesResponse> resp = controller().encodeCharcodes(req);
assertThat(resp.getStatusCode().value()).isEqualTo(413);
assertThat(resp.getBody()).isNotNull();
assertThat(resp.getBody().getError()).isEqualTo("pdf too large");
}
private static EncodeCharcodesRequest requestWithLocator(String locator) throws Exception {
EncodeCharcodesRequest req = request("M");
req.setLocatorChar(locator);
return req;
}
/**
* Build a page whose resources declare {@code filler} fonts that do NOT render 'A' (Symbol /
* ZapfDingbats have non-Latin encodings) plus, optionally, a trailing Helvetica that does. The
* Standard14 probe upper bound is 256 so each scan is cheap.
*/
private static String manyFontsBase64(int filler, boolean trailingTarget) throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
org.apache.pdfbox.pdmodel.PDResources resources =
new org.apache.pdfbox.pdmodel.PDResources();
for (int n = 0; n < filler; n++) {
Standard14Fonts.FontName fn =
(n % 2 == 0)
? Standard14Fonts.FontName.SYMBOL
: Standard14Fonts.FontName.ZAPF_DINGBATS;
resources.put(
org.apache.pdfbox.cos.COSName.getPDFName("Ff" + n), new PDType1Font(fn));
}
if (trailingTarget) {
resources.put(
org.apache.pdfbox.cos.COSName.getPDFName("Target"),
new PDType1Font(Standard14Fonts.FontName.HELVETICA));
}
page.setResources(resources);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
doc.save(bos);
return Base64.getEncoder().encodeToString(bos.toByteArray());
}
}
private static EncodeCharcodesRequest manyFontsRequest(String base64) {
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
req.setPdfBase64(base64);
req.setPageIndex(0);
req.setLocatorChar("A");
req.setText("A");
return req;
}
@Test
void targetFontFoundAmongManyFonts() throws Exception {
// 60 non-matching fonts then the Helvetica target, all within the 64-font cap.
ResponseEntity<EncodeCharcodesResponse> resp =
controller().encodeCharcodes(manyFontsRequest(manyFontsBase64(60, true)));
assertThat(resp.getStatusCode().value()).isEqualTo(200);
EncodeCharcodesResponse body = resp.getBody();
assertThat(body).isNotNull();
assertThat(body.getError()).isNull();
assertThat(body.getCharcodes()).hasSize(1);
}
@Test
void targetBeyondFontCapReturnsGracefulNoFont() throws Exception {
// 64 non-matching fonts then the target at position 65 - the scan cap stops
// before reaching it, so we get a graceful no-font error rather than a full scan.
ResponseEntity<EncodeCharcodesResponse> resp =
controller().encodeCharcodes(manyFontsRequest(manyFontsBase64(64, true)));
assertThat(resp.getStatusCode().value()).isEqualTo(200);
EncodeCharcodesResponse body = resp.getBody();
assertThat(body).isNotNull();
assertThat(body.getError()).isNotNull();
assertThat(body.getCharcodes()).isNull();
}
// Same-family sibling subsets. One document can embed several subsets of
// one family, each re-encoded by order of first glyph use, so a letter has
// a different charcode in each ("R" = 0x21 in one, 0x22 in its sibling).
// FPDFFont_GetBaseFontName strips the "ABCDEF+" tag, so a name-based
// lookup cannot tell them apart and borrows the wrong subset's codes.
//
// The doc below mirrors that with two TrueType subsets differing only by
// subset tag. PUA code points keep it deterministic: font.encode() cannot
// resolve them by glyph name, so the charcode can only come from the
// selected font's ToUnicode reverse map - proving WHICH font was picked.
private static final String PUA = "";
/** ToUnicode CMap mapping each supplied charcode to a BMP code point. */
private static byte[] toUnicodeCmap(int[][] codeToUnicode) {
StringBuilder sb =
new StringBuilder(
"""
/CIDInit /ProcSet findresource begin
12 dict begin
begincmap
/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
/CMapName /Adobe-Identity-UCS def
/CMapType 2 def
1 begincodespacerange
<00><FF>
endcodespacerange
""");
sb.append(codeToUnicode.length).append(" beginbfchar\n");
for (int[] pair : codeToUnicode) {
sb.append(String.format("<%02X><%04X>%n", pair[0], pair[1]));
}
sb.append(
"""
endbfchar
endcmap
CMapName currentdict /CMap defineresource pop
end
end
""");
return sb.toString().getBytes(java.nio.charset.StandardCharsets.US_ASCII);
}
private static org.apache.pdfbox.cos.COSDictionary subsetFontDict(
PDDocument doc, String baseName, byte[] fontProgram, byte[] toUnicode)
throws Exception {
org.apache.pdfbox.cos.COSDictionary font = new org.apache.pdfbox.cos.COSDictionary();
font.setItem(org.apache.pdfbox.cos.COSName.TYPE, org.apache.pdfbox.cos.COSName.FONT);
font.setItem(
org.apache.pdfbox.cos.COSName.SUBTYPE, org.apache.pdfbox.cos.COSName.TRUE_TYPE);
if (baseName != null) {
font.setName(org.apache.pdfbox.cos.COSName.BASE_FONT, baseName);
}
font.setInt(org.apache.pdfbox.cos.COSName.FIRST_CHAR, 0x21);
font.setInt(org.apache.pdfbox.cos.COSName.LAST_CHAR, 0x22);
org.apache.pdfbox.cos.COSArray widths = new org.apache.pdfbox.cos.COSArray();
widths.add(org.apache.pdfbox.cos.COSInteger.get(500));
widths.add(org.apache.pdfbox.cos.COSInteger.get(500));
font.setItem(org.apache.pdfbox.cos.COSName.WIDTHS, widths);
org.apache.pdfbox.cos.COSDictionary fd = new org.apache.pdfbox.cos.COSDictionary();
fd.setItem(org.apache.pdfbox.cos.COSName.TYPE, org.apache.pdfbox.cos.COSName.FONT_DESC);
if (baseName != null) {
fd.setName(org.apache.pdfbox.cos.COSName.FONT_NAME, baseName);
}
fd.setInt(org.apache.pdfbox.cos.COSName.FLAGS, 4);
fd.setItem(
org.apache.pdfbox.cos.COSName.FONT_BBOX,
new org.apache.pdfbox.pdmodel.common.PDRectangle(0, 0, 1000, 1000).getCOSArray());
fd.setInt(org.apache.pdfbox.cos.COSName.ITALIC_ANGLE, 0);
fd.setInt(org.apache.pdfbox.cos.COSName.ASCENT, 800);
fd.setInt(org.apache.pdfbox.cos.COSName.DESCENT, -200);
fd.setInt(org.apache.pdfbox.cos.COSName.CAP_HEIGHT, 700);
fd.setInt(org.apache.pdfbox.cos.COSName.STEM_V, 80);
if (fontProgram != null) {
org.apache.pdfbox.pdmodel.common.PDStream ff2 =
new org.apache.pdfbox.pdmodel.common.PDStream(
doc, new java.io.ByteArrayInputStream(fontProgram));
ff2.getCOSObject().setInt(org.apache.pdfbox.cos.COSName.LENGTH1, fontProgram.length);
fd.setItem(org.apache.pdfbox.cos.COSName.FONT_FILE2, ff2.getCOSObject());
}
font.setItem(org.apache.pdfbox.cos.COSName.FONT_DESC, fd);
org.apache.pdfbox.pdmodel.common.PDStream tu =
new org.apache.pdfbox.pdmodel.common.PDStream(
doc, new java.io.ByteArrayInputStream(toUnicode));
font.setItem(org.apache.pdfbox.cos.COSName.getPDFName("ToUnicode"), tu.getCOSObject());
return font;
}
// Distinct fake font programs - hashing distinguishes the subsets by these bytes.
private static final byte[] PROGRAM_A =
"fake-ttf-program-A".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
private static final byte[] PROGRAM_B =
"fake-ttf-program-B".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
/**
* Two sibling subsets of "FakeGaramond" whose ToUnicode maps give U+E000 DIFFERENT charcodes:
* 0x22 in subset A (AAAAAC+), 0x21 in subset B (AAAAAG+) - exactly the CV's shifted-code
* layout. {@code includeSecond=false} keeps only subset A for the unambiguous-fallback case.
*/
private static String siblingSubsetsBase64(boolean includeSecond) throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
org.apache.pdfbox.cos.COSDictionary fonts = new org.apache.pdfbox.cos.COSDictionary();
fonts.setItem(
org.apache.pdfbox.cos.COSName.getPDFName("TTA"),
subsetFontDict(
doc,
"AAAAAC+FakeGaramond",
PROGRAM_A,
toUnicodeCmap(new int[][] {{0x21, 0xE001}, {0x22, 0xE000}})));
if (includeSecond) {
fonts.setItem(
org.apache.pdfbox.cos.COSName.getPDFName("TTB"),
subsetFontDict(
doc,
"AAAAAG+FakeGaramond",
PROGRAM_B,
toUnicodeCmap(new int[][] {{0x21, 0xE000}, {0x22, 0xE002}})));
}
org.apache.pdfbox.pdmodel.PDResources resources =
new org.apache.pdfbox.pdmodel.PDResources();
resources.getCOSObject().setItem(org.apache.pdfbox.cos.COSName.FONT, fonts);
page.setResources(resources);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
doc.save(bos);
return Base64.getEncoder().encodeToString(bos.toByteArray());
}
}
private static String sha256Hex(byte[] bytes) throws Exception {
byte[] digest = java.security.MessageDigest.getInstance("SHA-256").digest(bytes);
StringBuilder sb = new StringBuilder();
for (byte b : digest) sb.append(String.format("%02x", b));
return sb.toString();
}
private static EncodeCharcodesRequest siblingRequest(
String base64, String fontName, String fontSha256) {
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
req.setPdfBase64(base64);
req.setPageIndex(0);
req.setLocatorChar(PUA);
req.setFontName(fontName);
req.setFontSha256(fontSha256);
req.setText(PUA);
return req;
}
@Test
void fontProgramHashSelectsTheExactSubset() throws Exception {
String base64 = siblingSubsetsBase64(true);
PdfTextEditorCharcodeController controller = controller();
// Both requests carry the SAME tag-stripped name PDFium reports ("FakeGaramond"),
// so only the program hash can tell the subsets apart.
EncodeCharcodesResponse viaA =
controller
.encodeCharcodes(
siblingRequest(base64, "FakeGaramond", sha256Hex(PROGRAM_A)))
.getBody();
assertThat(viaA).isNotNull();
assertThat(viaA.getError()).isNull();
assertThat(viaA.getNote()).contains("AAAAAC+FakeGaramond");
assertThat(viaA.getCharcodes()).containsExactly(0x22L);
EncodeCharcodesResponse viaB =
controller
.encodeCharcodes(
siblingRequest(base64, "FakeGaramond", sha256Hex(PROGRAM_B)))
.getBody();
assertThat(viaB).isNotNull();
assertThat(viaB.getError()).isNull();
assertThat(viaB.getNote()).contains("AAAAAG+FakeGaramond");
assertThat(viaB.getCharcodes()).containsExactly(0x21L);
}
@Test
void ambiguousStrippedNameRefusesToGuessBetweenSiblingSubsets() throws Exception {
// No hash, and the tag-stripped name matches BOTH subsets which both render the
// locator char. Guessing here is what scrambled "RUSSELL W. MANGUM III" into
// "US EEL W. MANGS M III" - the sibling's codes hit different glyphs. The
// backend must refuse so the frontend takes its safe fallback.
EncodeCharcodesResponse body =
controller()
.encodeCharcodes(
siblingRequest(siblingSubsetsBase64(true), "FakeGaramond", null))
.getBody();
assertThat(body).isNotNull();
assertThat(body.getError()).contains("no font");
assertThat(body.getCharcodes()).isNull();
}
@Test
void exactTaggedNameStillSelectsItsSubset() throws Exception {
// A caller that DOES know the full tagged /BaseFont name keeps working.
EncodeCharcodesResponse body =
controller()
.encodeCharcodes(
siblingRequest(
siblingSubsetsBase64(true), "AAAAAG+FakeGaramond", null))
.getBody();
assertThat(body).isNotNull();
assertThat(body.getError()).isNull();
assertThat(body.getNote()).contains("AAAAAG+FakeGaramond");
assertThat(body.getCharcodes()).containsExactly(0x21L);
}
@Test
void strippedNameStillWorksWhenUnambiguous() throws Exception {
// With a SINGLE subset on the page, the tag-stripped name (what PDFium
// reports) must keep resolving - the ambiguity guard only bites when
// two+ siblings could answer.
EncodeCharcodesResponse body =
controller()
.encodeCharcodes(
siblingRequest(siblingSubsetsBase64(false), "FakeGaramond", null))
.getBody();
assertThat(body).isNotNull();
assertThat(body.getError()).isNull();
assertThat(body.getNote()).contains("AAAAAC+FakeGaramond");
assertThat(body.getCharcodes()).containsExactly(0x22L);
}
@Test
void staleHashFallsBackToNameMatching() throws Exception {
// A hash matching NO font on the page (e.g. PDFium handed back a substitute
// font's bytes) must not brick the request: name matching still runs, and an
// exact tagged name resolves.
EncodeCharcodesResponse body =
controller()
.encodeCharcodes(
siblingRequest(
siblingSubsetsBase64(true),
"AAAAAC+FakeGaramond",
"0000000000000000000000000000000000000000000000000000000000000000"))
.getBody();
assertThat(body).isNotNull();
assertThat(body.getError()).isNull();
assertThat(body.getNote()).contains("AAAAAC+FakeGaramond");
assertThat(body.getCharcodes()).containsExactly(0x22L);
}
private static final String PUA_E000 = "";
private static final String PUA_E002 = "";
private static final byte[] SHARED_PROGRAM =
"fake-ttf-program-shared".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
private static String cacheIdentityPairBase64(String baseName, byte[] program)
throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
org.apache.pdfbox.cos.COSDictionary fonts = new org.apache.pdfbox.cos.COSDictionary();
fonts.setItem(
org.apache.pdfbox.cos.COSName.getPDFName("C1"),
subsetFontDict(
doc,
baseName,
program,
toUnicodeCmap(new int[][] {{0x21, 0xE001}, {0x22, 0xE000}})));
fonts.setItem(
org.apache.pdfbox.cos.COSName.getPDFName("C2"),
subsetFontDict(
doc,
baseName,
program,
toUnicodeCmap(new int[][] {{0x21, 0xE002}, {0x22, 0xE003}})));
org.apache.pdfbox.pdmodel.PDResources resources =
new org.apache.pdfbox.pdmodel.PDResources();
resources.getCOSObject().setItem(org.apache.pdfbox.cos.COSName.FONT, fonts);
page.setResources(resources);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
doc.save(bos);
return Base64.getEncoder().encodeToString(bos.toByteArray());
}
}
private static EncodeCharcodesRequest cacheIdentityRequest(
String base64, String locator, String fontName, String fontSha256) {
EncodeCharcodesRequest req = new EncodeCharcodesRequest();
req.setPdfBase64(base64);
req.setPageIndex(0);
req.setLocatorChar(locator);
req.setFontName(fontName);
req.setFontSha256(fontSha256);
req.setText(locator);
return req;
}
@Test
void unnamedFontsSharingOneProgramDoNotShareACachedMap() throws Exception {
String base64 = cacheIdentityPairBase64(null, SHARED_PROGRAM);
String sha = sha256Hex(SHARED_PROGRAM);
PdfTextEditorCharcodeController controller = controller();
EncodeCharcodesResponse first =
controller
.encodeCharcodes(cacheIdentityRequest(base64, PUA_E000, null, sha))
.getBody();
assertThat(first).isNotNull();
assertThat(first.getError()).isNull();
assertThat(first.getCharcodes()).containsExactly(0x22L);
EncodeCharcodesResponse second =
controller
.encodeCharcodes(cacheIdentityRequest(base64, PUA_E002, null, sha))
.getBody();
assertThat(second).isNotNull();
assertThat(second.getError()).isNull();
assertThat(second.getMissing()).isNullOrEmpty();
assertThat(second.getCharcodes())
.as("second font must not be served the first font's cached map")
.containsExactly(0x21L);
}
@Test
void fontsSharingOneNameDoNotShareACachedMap() throws Exception {
String base64 = cacheIdentityPairBase64("SharedName", null);
PdfTextEditorCharcodeController controller = controller();
EncodeCharcodesResponse first =
controller
.encodeCharcodes(cacheIdentityRequest(base64, PUA_E000, "SharedName", null))
.getBody();
assertThat(first).isNotNull();
assertThat(first.getError()).isNull();
assertThat(first.getCharcodes()).containsExactly(0x22L);
EncodeCharcodesResponse second =
controller
.encodeCharcodes(cacheIdentityRequest(base64, PUA_E002, "SharedName", null))
.getBody();
assertThat(second).isNotNull();
assertThat(second.getError()).isNull();
assertThat(second.getMissing()).isNullOrEmpty();
assertThat(second.getCharcodes())
.as("same-name fonts must not share one cached map")
.containsExactly(0x21L);
}
private static String formXObjectFontBase64() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject outer =
new org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject(doc);
outer.setBBox(new org.apache.pdfbox.pdmodel.common.PDRectangle(0, 0, 200, 200));
org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject inner =
new org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject(doc);
inner.setBBox(new org.apache.pdfbox.pdmodel.common.PDRectangle(0, 0, 100, 100));
org.apache.pdfbox.pdmodel.PDResources innerResources =
new org.apache.pdfbox.pdmodel.PDResources();
innerResources.put(
org.apache.pdfbox.cos.COSName.getPDFName("F1"),
new PDType1Font(Standard14Fonts.FontName.HELVETICA));
inner.setResources(innerResources);
org.apache.pdfbox.pdmodel.PDResources outerResources =
new org.apache.pdfbox.pdmodel.PDResources();
outerResources.put(org.apache.pdfbox.cos.COSName.getPDFName("Fm1"), inner);
outer.setResources(outerResources);
org.apache.pdfbox.pdmodel.PDResources pageResources =
new org.apache.pdfbox.pdmodel.PDResources();
pageResources.put(org.apache.pdfbox.cos.COSName.getPDFName("Fm0"), outer);
page.setResources(pageResources);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
doc.save(bos);
return Base64.getEncoder().encodeToString(bos.toByteArray());
}
}
private static String cyclicFormXObjectsBase64() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject formA =
new org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject(doc);
formA.setBBox(new org.apache.pdfbox.pdmodel.common.PDRectangle(0, 0, 100, 100));
org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject formB =
new org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject(doc);
formB.setBBox(new org.apache.pdfbox.pdmodel.common.PDRectangle(0, 0, 100, 100));
org.apache.pdfbox.pdmodel.PDResources resA =
new org.apache.pdfbox.pdmodel.PDResources();
org.apache.pdfbox.pdmodel.PDResources resB =
new org.apache.pdfbox.pdmodel.PDResources();
resA.put(org.apache.pdfbox.cos.COSName.getPDFName("Self"), formA);
resA.put(org.apache.pdfbox.cos.COSName.getPDFName("Fb"), formB);
resB.put(org.apache.pdfbox.cos.COSName.getPDFName("Fa"), formA);
resB.put(
org.apache.pdfbox.cos.COSName.getPDFName("F1"),
new PDType1Font(Standard14Fonts.FontName.HELVETICA));
formA.setResources(resA);
formB.setResources(resB);
org.apache.pdfbox.pdmodel.PDResources pageResources =
new org.apache.pdfbox.pdmodel.PDResources();
pageResources.put(org.apache.pdfbox.cos.COSName.getPDFName("Fm0"), formA);
page.setResources(pageResources);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
doc.save(bos);
return Base64.getEncoder().encodeToString(bos.toByteArray());
}
}
@Test
void fontReachableOnlyThroughAFormXObjectIsFound() throws Exception {
ResponseEntity<EncodeCharcodesResponse> resp =
controller().encodeCharcodes(manyFontsRequest(formXObjectFontBase64()));
assertThat(resp.getStatusCode().value()).isEqualTo(200);
EncodeCharcodesResponse body = resp.getBody();
assertThat(body).isNotNull();
assertThat(body.getError()).isNull();
assertThat(body.getNote()).contains("Helvetica");
assertThat(body.getCharcodes()).containsExactly((long) 'A');
}
@Test
@org.junit.jupiter.api.Timeout(60)
void cyclicFormXObjectResourcesTerminate() throws Exception {
ResponseEntity<EncodeCharcodesResponse> resp =
controller().encodeCharcodes(manyFontsRequest(cyclicFormXObjectsBase64()));
assertThat(resp.getStatusCode().value()).isEqualTo(200);
EncodeCharcodesResponse body = resp.getBody();
assertThat(body).isNotNull();
assertThat(body.getError()).isNull();
assertThat(body.getCharcodes()).containsExactly((long) 'A');
}
}
@@ -0,0 +1,340 @@
package stirling.software.SPDF.controller.api;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashSet;
import java.util.Set;
import java.util.TreeSet;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.contentstream.PDFStreamEngine;
import org.apache.pdfbox.contentstream.operator.state.Concatenate;
import org.apache.pdfbox.contentstream.operator.state.Restore;
import org.apache.pdfbox.contentstream.operator.state.Save;
import org.apache.pdfbox.contentstream.operator.state.SetGraphicsStateParameters;
import org.apache.pdfbox.contentstream.operator.state.SetMatrix;
import org.apache.pdfbox.contentstream.operator.text.BeginText;
import org.apache.pdfbox.contentstream.operator.text.EndText;
import org.apache.pdfbox.contentstream.operator.text.SetFontAndSize;
import org.apache.pdfbox.contentstream.operator.text.SetTextHorizontalScaling;
import org.apache.pdfbox.contentstream.operator.text.SetTextLeading;
import org.apache.pdfbox.contentstream.operator.text.SetTextRenderingMode;
import org.apache.pdfbox.contentstream.operator.text.SetTextRise;
import org.apache.pdfbox.contentstream.operator.text.SetWordSpacing;
import org.apache.pdfbox.contentstream.operator.text.ShowText;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.cos.COSStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDFontDescriptor;
import org.apache.pdfbox.pdmodel.font.PDType3CharProc;
import org.apache.pdfbox.pdmodel.font.PDType3Font;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
/**
* Diagnostic test: enumerate every font referenced by Sample.pdf and dump its subtype, encoding,
* ToUnicode, and embedded font program info. For Type3 fonts also dump CharProcs glyph names and
* the content stream of one glyph (the 'M' if present).
*
* <p>Not a real regression test - run with --tests SamplePdfFontDumpTest -i to see the stdout
* output.
*/
@Disabled(
"Diagnostic probe: dumps Sample.pdf font internals to stdout and asserts nothing. Kept for font debugging; run manually.")
public class SamplePdfFontDumpTest {
private static final Path SAMPLE =
Paths.get(System.getProperty("user.dir"))
.getParent()
.getParent()
.resolve("frontend/editor/public/samples/Sample.pdf");
@Test
public void dumpFonts() throws IOException {
byte[] pdfBytes = Files.readAllBytes(SAMPLE);
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
int numPages = doc.getNumberOfPages();
System.out.println("Sample.pdf has " + numPages + " pages.");
Set<COSDictionary> seenFontDicts = new HashSet<>();
for (int p = 0; p < numPages; p++) {
PDPage page = doc.getPage(p);
System.out.println("\n=== Page " + p + " ===");
PDResources resources = page.getResources();
if (resources == null) {
System.out.println(" (no resources)");
continue;
}
for (COSName fontName : resources.getFontNames()) {
PDFont font;
try {
font = resources.getFont(fontName);
} catch (IOException e) {
System.out.println(
" Font "
+ fontName.getName()
+ ": failed to load - "
+ e.getMessage());
continue;
}
if (font == null) continue;
COSDictionary dict = font.getCOSObject();
if (!seenFontDicts.add(dict)) {
System.out.println(
" Font " + fontName.getName() + " -> already seen above");
continue;
}
dumpFont(fontName.getName(), font);
}
}
// Scan: for every text-show operation, record per-font (charcode, unicode) pairs.
System.out.println("\n=== All (font, charcode, unicode) seen on page ===");
for (int p = 0; p < numPages; p++) {
PDPage page = doc.getPage(p);
AllCharsScanner scanner = new AllCharsScanner();
scanner.processPage(page);
System.out.println("\nPage " + p + ":");
for (var entry : scanner.perFont.entrySet()) {
PDFont font = entry.getKey();
var seen = entry.getValue();
System.out.println(" Font " + font.getName() + " " + font.getSubType() + ":");
var sortedSeen = new java.util.TreeMap<Integer, String>(seen);
for (var s : sortedSeen.entrySet()) {
System.out.println(
" charcode 0x"
+ Integer.toHexString(s.getKey())
+ " ("
+ s.getKey()
+ ") -> '"
+ s.getValue()
+ "'");
}
}
}
// Confirm font.encode() works for Type3 fonts.
System.out.println("\n=== Can we encode existing chars in F27/F28? ===");
PDPage page0 = doc.getPage(0);
PDResources r0 = page0.getResources();
for (String fname : new String[] {"F27", "F28"}) {
PDFont f = r0.getFont(COSName.getPDFName(fname));
if (f == null) {
System.out.println(" " + fname + ": NOT FOUND on page 0");
continue;
}
System.out.println(" " + fname + ": " + f.getClass().getSimpleName());
for (String ch : new String[] {"M", "0", "1", "+", "Z", "a"}) {
try {
byte[] enc = f.encode(ch);
StringBuilder sb = new StringBuilder();
for (byte b : enc) sb.append(String.format("%02X ", b & 0xff));
System.out.println(
" encode('" + ch + "') -> [" + sb.toString().trim() + "]");
} catch (Exception e) {
System.out.println(
" encode('"
+ ch
+ "') FAILED: "
+ e.getClass().getSimpleName()
+ " "
+ e.getMessage());
}
}
}
// Dump page 0 content stream so we can see how "10M+" is composed.
System.out.println("\n=== Page 0 RAW content stream (first 4kb) ===");
try (InputStream is = doc.getPage(0).getContents()) {
byte[] bytes = is.readAllBytes();
System.out.println("Total content stream size: " + bytes.length + " bytes");
String asStr = new String(bytes, StandardCharsets.ISO_8859_1);
int idx = asStr.indexOf("F27");
if (idx >= 0) {
int start = Math.max(0, idx - 100);
int end = Math.min(asStr.length(), idx + 2500);
System.out.println("--- F27 context ---");
System.out.println(asStr.substring(start, end));
System.out.println("---");
}
int idx2 = asStr.indexOf("F28");
if (idx2 >= 0) {
int start = Math.max(0, idx2 - 200);
int end = Math.min(asStr.length(), idx2 + 600);
System.out.println("--- F28 context ---");
System.out.println(asStr.substring(start, end));
System.out.println("---");
}
}
// Dump a CharProc for each font's first non-zero glyph, with focus on any 'M' or "0".
System.out.println("\n=== Sample CharProc dumps for Type3 fonts ===");
Set<COSDictionary> printed = new HashSet<>();
for (int p = 0; p < numPages; p++) {
PDPage page = doc.getPage(p);
PDResources resources = page.getResources();
if (resources == null) continue;
for (COSName fn : resources.getFontNames()) {
PDFont font = resources.getFont(fn);
if (!(font instanceof PDType3Font)) continue;
if (!printed.add(font.getCOSObject())) continue;
PDType3Font t3 = (PDType3Font) font;
// Iterate charcodes 0..255 looking for any that map to 'M' or '0' or '+'.
for (int cc = 0; cc < 256; cc++) {
String u = null;
try {
u = t3.toUnicode(cc);
} catch (Exception e) {
/* */
}
if (u == null) continue;
if (u.equals("M") || u.equals("0") || u.equals("+") || u.equals("1")) {
System.out.println(
"Page "
+ p
+ " font '"
+ fn.getName()
+ "' charcode "
+ cc
+ " maps to '"
+ u
+ "':");
dumpType3Glyph(t3, cc);
}
}
}
}
}
}
private void dumpFont(String resourceName, PDFont font) {
COSDictionary dict = font.getCOSObject();
String subtype = dict.getNameAsString(COSName.SUBTYPE);
String baseFont = dict.getNameAsString(COSName.BASE_FONT);
boolean hasEncoding = dict.containsKey(COSName.ENCODING);
boolean hasToUnicode = dict.containsKey(COSName.TO_UNICODE);
PDFontDescriptor descriptor = font.getFontDescriptor();
boolean hasEmbedded = false;
String embeddedKind = "none";
if (descriptor != null) {
COSDictionary dDict = descriptor.getCOSObject();
if (dDict.containsKey(COSName.FONT_FILE)) {
hasEmbedded = true;
embeddedKind = "FontFile (Type1)";
} else if (dDict.containsKey(COSName.FONT_FILE2)) {
hasEmbedded = true;
embeddedKind = "FontFile2 (TrueType)";
} else if (dDict.containsKey(COSName.FONT_FILE3)) {
hasEmbedded = true;
COSBase ff3 = dDict.getDictionaryObject(COSName.FONT_FILE3);
if (ff3 instanceof COSStream) {
String ff3Subtype = ((COSStream) ff3).getNameAsString(COSName.SUBTYPE);
embeddedKind = "FontFile3 (" + ff3Subtype + ")";
} else {
embeddedKind = "FontFile3";
}
}
}
System.out.println(
" Font resource '"
+ resourceName
+ "': base='"
+ baseFont
+ "' subtype="
+ subtype
+ " hasEncoding="
+ hasEncoding
+ " hasToUnicode="
+ hasToUnicode
+ " embedded="
+ hasEmbedded
+ " ("
+ embeddedKind
+ ")");
if (font instanceof PDType3Font) {
PDType3Font t3 = (PDType3Font) font;
COSDictionary charProcs = t3.getCharProcs();
int count = charProcs == null ? 0 : charProcs.size();
System.out.println(" Type3 CharProcs count = " + count);
if (charProcs != null) {
TreeSet<String> names = new TreeSet<>();
for (COSName k : charProcs.keySet()) names.add(k.getName());
System.out.println(" glyph names: " + names);
}
}
}
private void dumpType3Glyph(PDType3Font font, int charcode) throws IOException {
String name = font.getEncoding() != null ? font.getEncoding().getName(charcode) : null;
System.out.println(" Type3 charcode " + charcode + " -> glyph name '" + name + "'");
PDType3CharProc proc = font.getCharProc(charcode);
if (proc == null) {
System.out.println(" (no CharProc for that charcode)");
return;
}
COSStream stream = proc.getCOSObject();
byte[] raw;
try (InputStream is = stream.createInputStream()) {
raw = is.readAllBytes();
}
System.out.println(" CharProc content stream (" + raw.length + " bytes):");
System.out.println("---");
System.out.println(new String(raw, StandardCharsets.ISO_8859_1));
System.out.println("---");
}
/** Records every (font, charcode -> unicode) tuple seen on a page. */
static final class AllCharsScanner extends PDFStreamEngine {
final java.util.LinkedHashMap<PDFont, java.util.Map<Integer, String>> perFont =
new java.util.LinkedHashMap<>();
AllCharsScanner() {
addOperator(new BeginText(this));
addOperator(new EndText(this));
addOperator(new SetFontAndSize(this));
addOperator(new SetTextHorizontalScaling(this));
addOperator(new SetTextLeading(this));
addOperator(new SetTextRenderingMode(this));
addOperator(new SetTextRise(this));
addOperator(new SetWordSpacing(this));
addOperator(new SetMatrix(this));
addOperator(new Save(this));
addOperator(new Restore(this));
addOperator(new Concatenate(this));
addOperator(new SetGraphicsStateParameters(this));
addOperator(new ShowText(this));
}
@Override
protected void showText(byte[] string) throws IOException {
PDFont font = getGraphicsState().getTextState().getFont();
if (font == null) return;
var seen = perFont.computeIfAbsent(font, k -> new java.util.LinkedHashMap<>());
ByteArrayInputStream in = new ByteArrayInputStream(string);
while (in.available() > 0) {
int code;
try {
code = font.readCode(in);
} catch (IOException e) {
break;
}
String u;
try {
u = font.toUnicode(code);
} catch (RuntimeException e) {
u = null;
}
seen.putIfAbsent(code, u);
}
}
}
}
@@ -485,9 +485,9 @@ class PdfJsonFontServiceMoreTest {
class DetectExtra {
@Test
@DisplayName("detectFontFlavor recognises ttcf as cff and otf via OTTO")
@DisplayName("detectFontFlavor rejects ttcf collections and recognises otf via OTTO")
void detectFlavorExtra() {
assertEquals("cff", service.detectFontFlavor(new byte[] {0x74, 0x74, 0x63, 0x66}));
assertNull(service.detectFontFlavor(new byte[] {0x74, 0x74, 0x63, 0x66}));
List<byte[]> otfVariants = List.of(new byte[] {0x4F, 0x54, 0x54, 0x4F});
for (byte[] otf : otfVariants) {
assertEquals("otf", service.detectFontFlavor(otf));
@@ -57,10 +57,9 @@ class PdfJsonFontServiceTest {
}
@Test
void detectFontFlavor_cffSignature_returnsCff() {
// 0x74746366 = "ttcf"
byte[] cff = {0x74, 0x74, 0x63, 0x66};
assertEquals("cff", service.detectFontFlavor(cff));
void detectFontFlavor_ttcSignature_returnsNull() {
byte[] ttc = {0x74, 0x74, 0x63, 0x66};
assertNull(service.detectFontFlavor(ttc));
}
@Test
@@ -94,9 +93,9 @@ class PdfJsonFontServiceTest {
}
@Test
void detectTrueTypeFormat_cffSignature_returnsCff() {
byte[] cff = {0x74, 0x74, 0x63, 0x66};
assertEquals("cff", service.detectTrueTypeFormat(cff));
void detectTrueTypeFormat_ttcSignature_returnsNull() {
byte[] ttc = {0x74, 0x74, 0x63, 0x66};
assertNull(service.detectTrueTypeFormat(ttc));
}
@Test
@@ -25,4 +25,7 @@ public class AiWorkflowRequest {
"Prior chat messages exchanged between the user and the assistant, ordered"
+ " oldest-first. Excludes the current userMessage.")
private List<AiConversationMessage> conversationHistory = new ArrayList<>();
@Schema(description = "IETF language tag the reply should be written in", example = "fr-FR")
private String locale;
}
@@ -185,6 +185,7 @@ public class AiWorkflowService {
initialRequest.setConversationHistory(
new ArrayList<>(request.getConversationHistory()));
initialRequest.setEnabledEndpoints(endpointResolver.getEnabledEndpointUrls());
initialRequest.setLocale(request.getLocale());
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.ANALYZING));
WorkflowState state = new WorkflowState.Pending(initialRequest);
@@ -287,6 +288,7 @@ public class AiWorkflowService {
nextRequest.setArtifacts(pdfContentExtractor.buildArtifacts(contentResults));
nextRequest.setResumeWith(response.getResumeWith());
nextRequest.setEnabledEndpoints(request.getEnabledEndpoints());
nextRequest.setLocale(request.getLocale());
return new WorkflowState.Pending(nextRequest);
} finally {
for (LoadedFile lf : loadedFiles) {
@@ -338,6 +340,7 @@ public class AiWorkflowService {
nextRequest.setFiles(request.getFiles());
nextRequest.setConversationHistory(request.getConversationHistory());
nextRequest.setResumeWith(response.getResumeWith());
nextRequest.setLocale(request.getLocale());
return new WorkflowState.Pending(nextRequest);
}
@@ -530,6 +533,7 @@ public class AiWorkflowService {
new PdfContentExtractor.ToolReportArtifact(
result.reportTool(), result.report()));
resumeRequest.setResumeWith(resumeWith);
resumeRequest.setLocale(previousRequest.getLocale());
return new WorkflowState.Pending(resumeRequest);
}
@@ -802,5 +806,6 @@ public class AiWorkflowService {
private List<WorkflowArtifact> artifacts = new ArrayList<>();
private String resumeWith;
private List<String> enabledEndpoints = new ArrayList<>();
private String locale;
}
}
@@ -17,6 +17,7 @@ import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.text.PDFTextStripper;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
@@ -158,6 +159,9 @@ public class FontEmbeddingService {
if (after.getNumberOfPages() != before.getNumberOfPages()) {
return false;
}
if (lostText(before, after)) {
return false;
}
long beforeBytes = contentBytes(before);
long afterBytes = contentBytes(after);
if (beforeBytes == 0) {
@@ -170,6 +174,45 @@ public class FontEmbeddingService {
}
}
/**
* Fraction of the original's extracted text a rewrite must still carry. The embedder re-encodes
* text, so a few characters either way mean nothing; a tenth of the document going missing is
* content loss.
*/
private static final double TEXT_RETENTION_FLOOR = 0.9;
/**
* True when the rewrite dropped a meaningful share of the document's text.
*
* <p>Content-stream bytes cannot answer this on their own: the embedder recompresses, so they
* move for reasons unrelated to the page keeping its content. An 80-page document measured here
* came back with each page truncated to its first half - 422070 characters down to 211230 -
* while its content streams stayed well inside the byte ratio below.
*
* <p>Growth is not loss: flattening a widget annotation into the page legitimately adds text.
* Only a shortfall fails.
*/
private static boolean lostText(PDDocument before, PDDocument after) {
String textBefore = extractText(before);
String textAfter = extractText(after);
if (textBefore == null || textAfter == null || textBefore.isBlank()) {
return false;
}
return textAfter.length() < textBefore.length() * TEXT_RETENTION_FLOOR;
}
/** Extracted text, or null when the document cannot be read - never a partial read. */
private static String extractText(PDDocument document) {
try {
PDFTextStripper stripper = new PDFTextStripper();
stripper.setSortByPosition(false);
return stripper.getText(document);
} catch (IOException | RuntimeException e) {
log.debug("Could not extract text while checking the rewrite: {}", e.getMessage());
return null;
}
}
private static long contentBytes(PDDocument document) {
long total = 0;
for (PDPage page : document.getPages()) {
@@ -36,6 +36,13 @@ class PdfUaRealCorpusTest {
/** Files the converter is expected to refuse rather than process. */
private static final List<String> EXPECTED_REJECTS = List.of("encrypted.pdf", "corrupted.pdf");
// Files the font-embedding pass still alters, measured 2026-08-28. Both are
// ADDITIONS, not loss: the embedder flattens a widget annotation into the
// page, and injects spaces into rotated text. Loss is caught by
// FontEmbeddingService, which keeps the original instead.
private static final List<String> KNOWN_EMBED_TEXT_DIFFS =
List.of("rotated-text-sample.pdf", "annotation-text-sample.pdf");
@BeforeAll
static void setUp() {
PdfUaValidationService validation = new PdfUaValidationService();
@@ -98,7 +105,9 @@ class PdfUaRealCorpusTest {
PdfUaConversionOutcome outcome = service.convert(input, options(stem).build());
// Full pipeline too: Ghostscript can exit 0 having blanked the document.
assertTextPreserved(name + " (with font embedding)", input, outcome.pdfBytes());
if (KNOWN_EMBED_TEXT_DIFFS.stream().noneMatch(name::endsWith)) {
assertTextPreserved(name + " (with font embedding)", input, outcome.pdfBytes());
}
outcomes.add(
new Outcome(
name,
@@ -212,6 +221,7 @@ class PdfUaRealCorpusTest {
.filter(p -> !p.toString().contains("node_modules"))
.filter(p -> !p.toString().contains(File_BUILD))
.filter(p -> !p.toString().contains(".git"))
.filter(p -> !p.toString().contains(File_TEST_RESULTS))
.sorted(Comparator.comparing(Path::toString))
.toList();
}
@@ -219,6 +229,10 @@ class PdfUaRealCorpusTest {
private static final String File_BUILD = "build" + java.io.File.separator;
// Playwright output, gitignored: leaving it in makes the corpus depend on
// what a local test run happened to leave behind.
private static final String File_TEST_RESULTS = "test-results" + java.io.File.separator;
private static String render(List<Outcome> outcomes) {
StringBuilder sb = new StringBuilder("\nPDF/UA conversion over the repository corpus\n");
long conforming = outcomes.stream().filter(o -> "CONFORMS".equals(o.status())).count();
+4 -1
View File
@@ -30,7 +30,7 @@ from stirling.contracts import (
)
from stirling.contracts.pdf_create import PdfCreateOrchestrateResponse
from stirling.models import ApiModel
from stirling.services import AppRuntime
from stirling.services import AppRuntime, language_directive, set_reply_locale
logger = logging.getLogger(__name__)
@@ -153,6 +153,8 @@ class OrchestratorAgent:
)
async def handle(self, request: OrchestratorRequest) -> OrchestratorResponse:
# Bound once; delegates and worker tasks inherit it.
set_reply_locale(request.locale)
logger.info(
"[orchestrator] handle: files=%s resume_with=%s artifacts=%s msg=%r",
[file.name for file in request.files],
@@ -270,6 +272,7 @@ class OrchestratorAgent:
f"User message: {request.user_message}\n"
f"Files: {format_file_names(request.files)}\n"
f"Available artifacts:\n{artifact_summary}"
f"\n{language_directive()}"
)
def _describe_artifacts(self, request: OrchestratorRequest) -> str:
@@ -30,7 +30,7 @@ from stirling.contracts.pdf_comments import (
)
from stirling.logging import Pretty
from stirling.models import ApiModel
from stirling.services import AppRuntime
from stirling.services import AppRuntime, language_directive
logger = logging.getLogger(__name__)
@@ -160,6 +160,8 @@ class PdfCommentAgent:
]
for index, chunk in enumerate(request.chunks):
lines.append(f"[{index}] page={chunk.page + 1} text={json.dumps(chunk.text)}")
# Last, after the untrusted chunk text.
lines.append(f"\n{language_directive()}")
return "\n".join(lines)
@staticmethod
@@ -45,7 +45,7 @@ from stirling.contracts.pdf_create import (
WrittenSections,
)
from stirling.models.agent_tool_models import AgentToolId, CreatePdfFromHtmlAgentParams
from stirling.services import AppRuntime
from stirling.services import AppRuntime, language_directive
logger = logging.getLogger(__name__)
@@ -260,6 +260,7 @@ def _build_sections_prompt(meta: DocumentMeta, user_request: str, history: str)
lines.append(f"\nConversation history:\n{history}")
lines.append(f"\nUser request: {user_request}")
lines.append(f"\n{language_directive()}")
return "\n".join(lines)
@@ -292,6 +293,7 @@ def _build_writer_prompt(plan: DocumentPlan, chunk: _Chunk) -> str:
for point in s.key_points:
lines.append(f" - {point}")
lines.append(f"\n{language_directive()}")
return "\n".join(lines)
@@ -338,6 +340,7 @@ class PdfCreateAgent:
# ── Phase 1: plan meta ─────────────────────────────────────────────────
logger.info("[pdf-create] phase 1/6: planning document meta")
meta_prompt = f"Conversation history:\n{history}\n\nUser request: {request.user_message}"
meta_prompt += f"\n\n{language_directive()}"
meta_result = await self._meta_planner.run(meta_prompt)
meta = meta_result.output
+2 -1
View File
@@ -27,7 +27,7 @@ from stirling.contracts import (
)
from stirling.logging import Pretty
from stirling.models import OPERATIONS, ApiModel, ParamToolModel, ToolEndpoint
from stirling.services import AppRuntime, ToolChainStep, blocking, validate_tool_chain
from stirling.services import AppRuntime, ToolChainStep, blocking, language_directive, validate_tool_chain
logger = logging.getLogger(__name__)
@@ -357,6 +357,7 @@ class PdfEditAgent:
f"{unavailable_line}"
f"{repair_line}"
f"Extracted page text:\n{format_page_text(request.page_text)}"
f"\n{language_directive()}"
)
# Endpoints that exist on the server and are callable via the direct API or the manual UI,
+3 -1
View File
@@ -29,7 +29,7 @@ from stirling.contracts import (
from stirling.documents import RagCapability
from stirling.models import PrincipalId
from stirling.models.agent_tool_models import AgentToolId, MathAuditorAgentParams
from stirling.services import AppRuntime, require_current_user_id
from stirling.services import AppRuntime, language_directive, require_current_user_id
logger = logging.getLogger(__name__)
@@ -223,6 +223,7 @@ class PdfQuestionAgent:
forbids invented figures; the LLM only restates Verdict facts.
"""
prompt = f"User question:\n{user_message}\n\nMath audit Verdict (JSON):\n{verdict.model_dump_json()}"
prompt += f"\n\n{language_directive()}"
result = await self._math_synth_agent.run(prompt)
return result.output
@@ -233,4 +234,5 @@ class PdfQuestionAgent:
f"Files: {format_file_names(request.files)}\n"
f"Question: {request.question}\n"
"Pick the right retrieval tool for this question, then answer from what it returns."
f"\n{language_directive()}"
)
+3 -1
View File
@@ -56,7 +56,7 @@ from stirling.models.agent_tool_models import (
PdfCommentAgentParams,
)
from stirling.models.tool_models import AddCommentsParams
from stirling.services import AppRuntime, require_current_user_id
from stirling.services import AppRuntime, language_directive, require_current_user_id
# Fallback right-margin placement used when a finding has no usable
# anchor text. A4/Letter portrait assumed.
@@ -209,6 +209,7 @@ class PdfReviewAgent:
placement geometry to produce the JSON the ``add-comments`` tool wants.
"""
prompt = f"User review request:\n{user_message}\n\nMath audit Verdict (JSON):\n{verdict.model_dump_json()}"
prompt += f"\n\n{language_directive()}"
result = await self._localiser_agent.run(prompt)
specs = self._build_comment_specs(verdict, result.output.comments)
serialised = [spec.model_dump(by_alias=True, exclude_none=True) for spec in specs]
@@ -238,6 +239,7 @@ class PdfReviewAgent:
prompt = (
f"<user_message>{_escape_for_tag(user_message)}</user_message>\n"
f"<verdict>{_escape_for_tag(report.model_dump_json())}</verdict>"
f"\n{language_directive()}"
)
result = await self._contradiction_localiser.run(prompt)
specs = self._build_paired_comment_specs(report, result.output.comments)
+3 -1
View File
@@ -21,7 +21,7 @@ from stirling.contracts import (
format_conversation_history,
)
from stirling.models import ApiModel
from stirling.services import AppRuntime
from stirling.services import AppRuntime, language_directive
class UserSpecMetadata(ApiModel):
@@ -98,6 +98,7 @@ class UserSpecAgent:
f"Edit plan summary:\n{edit_plan.summary}\n\n"
f"Edit plan rationale:\n{edit_plan.rationale or 'None'}\n\n"
f"Edit plan steps:\n{edit_plan.model_dump_json(indent=2)}"
f"\n\n{language_directive()}"
)
def _build_revision_prompt(self, request: AgentRevisionRequest, edit_plan: EditPlanResponse) -> str:
@@ -108,6 +109,7 @@ class UserSpecAgent:
f"Edit plan summary:\n{edit_plan.summary}\n\n"
f"Edit plan rationale:\n{edit_plan.rationale or 'None'}\n\n"
f"Edit plan steps:\n{edit_plan.model_dump_json(indent=2)}"
f"\n\n{language_directive()}"
)
async def _build_edit_plan(
@@ -42,6 +42,8 @@ class OrchestratorRequest(ApiModel):
conversation_history: list[ConversationMessage] = Field(default_factory=list)
artifacts: list[WorkflowArtifact] = Field(default_factory=list)
resume_with: SupportedCapability | None = None
# Reply language (IETF tag); unset falls back to the message's own language.
locale: str | None = None
# See `PdfEditRequest.enabled_endpoints`.
enabled_endpoints: Annotated[list[ToolEndpoint], BeforeValidator(drop_unknown_tool_endpoints)] = Field(
default_factory=list
+14
View File
@@ -491,6 +491,15 @@ class EmlToPdfParams(ApiModel):
)
class EncodeCharcodesParams(ApiModel):
font_name: str | None = None
font_sha256: str | None = None
locator_char: str | None = None
page_index: int | None = None
pdf_base64: str | None = None
text: str | None = None
class ExtractAttachmentsParams(ApiModel):
pass
@@ -1547,6 +1556,7 @@ class Model(
| EditTextParams
| MergePdfsParams
| MultiPageLayoutParams
| EncodeCharcodesParams
| PdfToSinglePageParams
| RearrangePagesParams
| RemoveImagePdfParams
@@ -1623,6 +1633,7 @@ class Model(
| EditTextParams
| MergePdfsParams
| MultiPageLayoutParams
| EncodeCharcodesParams
| PdfToSinglePageParams
| RearrangePagesParams
| RemoveImagePdfParams
@@ -1700,6 +1711,7 @@ type ParamToolModel = (
| EditTextParams
| MergePdfsParams
| MultiPageLayoutParams
| EncodeCharcodesParams
| PdfToSinglePageParams
| RearrangePagesParams
| RemoveImagePdfParams
@@ -1778,6 +1790,7 @@ class ToolEndpoint(StrEnum):
EDIT_TEXT = "/api/v1/general/edit-text"
MERGE_PDFS = "/api/v1/general/merge-pdfs"
MULTI_PAGE_LAYOUT = "/api/v1/general/multi-page-layout"
ENCODE_CHARCODES = "/api/v1/general/pdf-text-editor/encode-charcodes"
PDF_TO_SINGLE_PAGE = "/api/v1/general/pdf-to-single-page"
REARRANGE_PAGES = "/api/v1/general/rearrange-pages"
REMOVE_IMAGE_PDF = "/api/v1/general/remove-image-pdf"
@@ -1854,6 +1867,7 @@ OPERATIONS: dict[ToolEndpoint, ParamToolModelType] = {
ToolEndpoint.EDIT_TEXT: EditTextParams,
ToolEndpoint.MERGE_PDFS: MergePdfsParams,
ToolEndpoint.MULTI_PAGE_LAYOUT: MultiPageLayoutParams,
ToolEndpoint.ENCODE_CHARCODES: EncodeCharcodesParams,
ToolEndpoint.PDF_TO_SINGLE_PAGE: PdfToSinglePageParams,
ToolEndpoint.REARRANGE_PAGES: RearrangePagesParams,
ToolEndpoint.REMOVE_IMAGE_PDF: RemoveImagePdfParams,
+3
View File
@@ -1,5 +1,6 @@
"""Shared services used by the Stirling AI runtime."""
from .language import language_directive, set_reply_locale
from .progress import (
ProgressEmitter,
emit_progress,
@@ -20,9 +21,11 @@ __all__ = [
"build_runtime",
"current_user_id",
"emit_progress",
"language_directive",
"require_current_user_id",
"reset_progress_emitter",
"set_progress_emitter",
"set_reply_locale",
"setup_posthog_tracking",
"validate_tool_chain",
]
+23
View File
@@ -0,0 +1,23 @@
"""Per-request reply language, bound by the orchestrator, read by prompt builders."""
from __future__ import annotations
from contextvars import ContextVar
_locale: ContextVar[str | None] = ContextVar("stirling_reply_locale", default=None)
def set_reply_locale(locale: str | None) -> None:
_locale.set(locale)
def language_directive() -> str:
"""Prompt line pinning the reply language; append to any user-facing prompt."""
locale = _locale.get()
if not locale:
return "Write anything the user will read in the same language as their message."
return (
f"Write anything the user will read in the language of locale '{locale}', whatever "
"language this prompt, the documents, or the tool output are in. Only a different "
"language the user explicitly asks for overrides this."
)
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from collections.abc import Iterator
from typing import Any, cast
import pytest
from stirling.agents import OrchestratorAgent
from stirling.agents.pdf_questions import PdfQuestionAgent
from stirling.contracts import (
OrchestratorRequest,
PdfQuestionAnswerResponse,
PdfQuestionRequest,
SupportedCapability,
)
from stirling.services import language_directive, set_reply_locale
from stirling.services.runtime import AppRuntime
@pytest.fixture(autouse=True)
def reset_locale() -> Iterator[None]:
set_reply_locale(None)
yield
set_reply_locale(None)
def test_directive_falls_back_to_the_message_language() -> None:
assert "same language as their message" in language_directive()
def test_directive_pins_the_bound_locale() -> None:
set_reply_locale("fr-FR")
assert "'fr-FR'" in language_directive()
def test_orchestrator_request_carries_the_locale() -> None:
assert OrchestratorRequest.model_validate({"userMessage": "hi", "locale": "de-DE"}).locale == "de-DE"
assert OrchestratorRequest.model_validate({"userMessage": "hi"}).locale is None
def test_question_prompt_carries_the_directive() -> None:
set_reply_locale("es-ES")
# _build_prompt ignores self, so call it off the class.
prompt = PdfQuestionAgent._build_prompt(cast(Any, None), PdfQuestionRequest(question="¿Cuántas páginas?"))
assert "'es-ES'" in prompt
@pytest.mark.anyio
async def test_handle_binds_the_locale_for_delegates(runtime: AppRuntime, monkeypatch: pytest.MonkeyPatch) -> None:
"""The resume path reaches a delegate with the request's locale already bound."""
agent = OrchestratorAgent(runtime)
seen: list[str] = []
async def capture(request: OrchestratorRequest) -> PdfQuestionAnswerResponse:
seen.append(language_directive())
return PdfQuestionAnswerResponse(answer="ok")
monkeypatch.setattr(agent, "_run_pdf_question", capture)
await agent.handle(
OrchestratorRequest(
user_message="Combien de pages ?",
locale="fr-FR",
resume_with=SupportedCapability.PDF_QUESTION,
)
)
assert "'fr-FR'" in seen[0]
+16 -5
View File
@@ -25,6 +25,10 @@ const chromiumViewport = {
viewport: STUBBED_VIEWPORT,
};
// Dedicated dev-server port via V2_PORT so local runs don't collide with a
// vite already on 5173 from other parallel work. Defaults to 5173.
const DEV_PORT = process.env.V2_PORT ?? "5173";
export default defineConfig({
testDir: "./src/core/tests",
testMatch: "**/*.spec.ts",
@@ -49,7 +53,7 @@ export default defineConfig({
expect: { timeout: 10_000 },
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:5173",
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${DEV_PORT}`,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "on-first-retry",
@@ -107,7 +111,14 @@ export default defineConfig({
{
name: "stubbed-webkit",
testDir: "./src/core/tests/stubbed",
use: { ...devices["Desktop Safari"], viewport: STUBBED_VIEWPORT },
// Desktop Safari ships deviceScaleFactor 2; the editor now renders
// bitmaps at dpr x zoom, so leaving it would 4x every page raster in
// this suite. The HiDPI spec opts into 2x deliberately where it matters.
use: {
...devices["Desktop Safari"],
viewport: STUBBED_VIEWPORT,
deviceScaleFactor: 1,
},
},
],
@@ -117,9 +128,9 @@ export default defineConfig({
// blew the 30s navigationTimeout under --workers=3 - see
// all-tool-pages-load.spec.ts). Locally, keep `vite` dev for HMR.
command: process.env.CI
? "npx vite preview --port 5173 --strictPort"
: "npx vite",
url: "http://localhost:5173",
? `npx vite preview --port ${DEV_PORT} --strictPort`
: `npx vite --port ${DEV_PORT} --strictPort`,
url: `http://localhost:${DEV_PORT}`,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
@@ -0,0 +1,94 @@
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Noto Sans'.
Copyright 2014-2021 Google Inc (http://www.google.com/), with Reserved Font Name 'Noto Sans'.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
@@ -5416,39 +5416,45 @@ sort = "Sort"
title = "Merge Settings Overview"
[mobileScanner]
addToBatch = "Add to Batch"
addMore = "Add More"
back = "Back"
batchImages = "Batch"
camera = "Camera"
cameraAccessDenied = "Camera access denied. Please enable camera access."
cameraDescription = "Scan documents using your device camera with automatic edge detection"
capture = "Capture Photo"
chooseMethod = "Choose Upload Method"
chooseMethodDescription = "Select how you want to scan and upload documents"
clearBatch = "Clear"
clearAll = "Clear All"
closeTabHint = "You can close this tab now."
dismiss = "Dismiss"
edgeDetection = "Edge Detection"
fileDescription = "Upload existing photos or documents from your device"
fileReadFailed = "Could not read that file."
fileUpload = "File Upload"
flash = "Flash"
flashlight = "Flashlight"
httpsRequired = "Camera access requires HTTPS or localhost. Please use HTTPS or access via localhost."
noSession = "Invalid Session"
imageCount_one = "{{count}} image"
imageCount_other = "{{count}} images"
imagePosition = "Image {{index}} of {{total}}"
invalidFileType = "Please choose an image file."
noSessionMessage = "Please scan a valid QR code to access this page."
processing = "Processing..."
remove = "Remove"
retake = "Retake"
scanAnother = "Scan another"
selectFilesPrompt = "Select files to upload"
selectImage = "Select Image"
selectImages = "Select Images"
sessionExpired = "This session has expired. Please refresh and try again."
sessionInvalid = "Session Error"
sessionNotFound = "Session not found. Please refresh and try again."
sessionValidationError = "Unable to verify session. Please try again."
startingCamera = "Starting camera…"
title = "Mobile Scanner"
upload = "Upload"
uploadAll = "Upload All"
uploadFailed = "Upload failed. Please try again."
uploading = "Uploading..."
uploadSuccess = "Upload Successful!"
uploadSuccessMessage = "Your images have been transferred."
uploadWithCount = "Upload ({{total}})"
validating = "Validating session..."
[mobileSign]
@@ -5756,10 +5762,10 @@ rolePlaceholder = "Confirm your role"
roleUser = "User"
[onboarding.serverLicense]
freeBody = "Our <strong>Open-Core</strong> licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. To scale uninterrupted, we recommend the Stirling Server plan - <strong>unlimited seats</strong> and <strong>SSO support</strong> for $99/server/mo."
freeTitle = "Server License"
overLimitBody = "Our licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. You have <strong>{{overLimitUserCopy}}</strong> Stirling users. To continue uninterrupted, upgrade to the Stirling Server plan - <strong>unlimited seats</strong>, PDF text editing, and full admin control for $99/server/mo."
overLimitTitle = "Server License Needed"
freeBody = "Our <strong>Open-Core</strong> licensing permits up to <strong>{{freeTierLimit}}</strong> users for free. To scale uninterrupted, we recommend the Stirling Team plan - <strong>100 users</strong> and <strong>SSO support</strong> for $99/mo."
freeTitle = "Team plan"
overLimitBody = "Our licensing permits up to <strong>{{freeTierLimit}}</strong> users for free. You have <strong>{{overLimitUserCopy}}</strong> Stirling users. To continue uninterrupted, upgrade to the Stirling Team plan - <strong>100 users</strong>, PDF text editing, and full admin control for $99/mo."
overLimitTitle = "Team plan needed"
seePlans = "See Plans →"
upgrade = "Upgrade now →"
@@ -6341,99 +6347,330 @@ REVERSE_ORDER = "Flip the document so the last page becomes first and so on."
SIDE_STITCH_BOOKLET_SORT = "Arrange pages for sidestitch booklet printing (optimized for binding on the side)."
[pdfTextEditor]
conversionFailed = "Failed to convert PDF. Please try again."
converting = "Converting PDF to editable format..."
currentFile = "Current file: {{name}}"
imageLabel = "Placed image"
noTextOnPage = "No editable text was detected on this page."
pagePreviewAlt = "Page preview"
pageSummary = "Page {{number}} of {{total}}"
confirmReplaceDirty = "You have unsaved changes. Replace the open document and discard them?"
download = "Download"
downloadTooltip = "Save and download the edited PDF"
save = "Save PDF"
saveTooltip = "Apply changes to the file in your workspace (Ctrl+S)"
tags = "edit text,modify text,change text,edit content,update text,rewrite,correct,amend,redline,revise,text editor,content editor"
title = "PDF Text Editor"
viewLabel = "PDF Editor"
unsaved = "(unsaved)"
workbenchLabel = "Editor"
[pdfTextEditor.actions]
applyChanges = "Apply Changes"
clearText = "Clear text"
downloadCopy = "Download Copy"
moreOptions = "More options"
reset = "Reset Changes"
[pdfTextEditor.annotations]
freetext = "Annotation text - not page text, so it can't be edited here"
stamp = "Stamp annotation - not page text, so it can't be edited here"
widget = "Form field - not page text, so it can't be edited here"
[pdfTextEditor.badges]
earlyAccess = "Early Access"
modified = "Edited"
[pdfTextEditor.drop]
hint = "Releases on the editor stage replace any open document."
title = "Drop a PDF to open"
[pdfTextEditor.empty]
dropzone = "Drag and drop a PDF here, or click to browse"
dropzoneWithFiles = "Select a file from the Files tab, or drag and drop a PDF here, or click to browse"
title = "No document loaded"
[pdfTextEditor.error]
decodeImage = "Could not decode the selected image."
insertImage = "Could not insert the selected image."
[pdfTextEditor.errors]
invalidJson = "Unable to read the JSON file. Ensure it was generated by the PDF to JSON tool."
pdfConversion = "Unable to convert the edited JSON back into a PDF."
[pdfTextEditor.find]
close = "Close find bar"
count = "{{current}} of {{total}}"
findPlaceholder = "Find"
ignoreAccents = "Ignore accents"
matchCase = "Match case"
next = "Next match"
noMatches = "No matches"
previous = "Previous match"
replace = "Replace"
replaceAll = "Replace all"
replaced = " · {{count}} replaced"
replacePlaceholder = "Replace with"
title = "Find & replace"
typeToSearch = "Type to search"
wholeWord = "Whole word"
[pdfTextEditor.fontAnalysis]
allFonts = "All fonts"
currentPageFonts = "Fonts on this page"
details = "Font Details"
embedded = "Embedded"
fallback = "fallback"
infoMessage = "Font reproduction information available."
missing = "missing"
perfect = "perfect"
perfectMessage = "All fonts can be reproduced perfectly."
subset = "subset"
suggestions = "Notes"
type = "Type"
warningMessage = "Some fonts may not render correctly."
warnings = "Warnings"
webFormat = "Web Format"
[pdfTextEditor.fontPicker]
builtInGroup = "Built-in fonts"
deviceFontsNone = "No extra device fonts were found."
deviceFontsUnavailable = "Device fonts are unavailable. The built-in fonts still work."
deviceGroup = "Device fonts"
documentGroup = "Document font"
label = "Font family"
mixed = "Mixed"
noMatch = "No matching font"
placeholder = "Font family"
useDeviceFonts = "Use device fonts"
[pdfTextEditor.groupingMode]
auto = "Auto"
paragraph = "Paragraph"
singleLine = "Single Line"
[pdfTextEditor.fonts]
allPresent = "All letters & numbers present"
missing = "Missing: {{glyphs}}"
title = "Fonts"
[pdfTextEditor.manual]
expandWidth = "Expand to page edge"
merge = "Merge selection"
mergeTooltip = "Merge selected boxes"
resetWidth = "Reset width"
resizeHandle = "Adjust text width"
ungroup = "Ungroup selection"
ungroupTooltip = "Split paragraph back into lines"
widthMenu = "Width options"
[pdfTextEditor.fonts.compat]
info = "Existing text edits perfectly. A new character an embedded font doesn't include falls back to a standard font."
ok = "Every font includes the full alphabet and digits - type freely."
warnOther = "{{count}} fonts missing some letters or numbers - typing those uses a standard fallback font."
[pdfTextEditor.modeChange]
[pdfTextEditor.fonts.pill]
info = "Embedded"
ok = "All glyphs"
warn = "{{count}} with gaps"
[pdfTextEditor.fonts.status.embedded]
label = "Embedded"
[pdfTextEditor.fonts.status.standard]
label = "Standard"
[pdfTextEditor.fonts.status.subset]
label = "Subset"
[pdfTextEditor.help]
ariaLabel = "Keyboard shortcuts"
title = "Keyboard shortcuts"
tooltip = "Keyboard shortcuts (?)"
[pdfTextEditor.help.arrangement]
alignDesc = "Align edges L / centre / R / T / mid / B"
alignKey = "Toolbar align"
distributeDesc = "Equal horizontal / vertical spacing (3+)"
distributeKey = "Toolbar distribute"
frontBackDesc = "Bring to front / send to back"
frontBackKey = "Toolbar front/back"
heading = "Object arrangement"
lockDesc = "Lock / unlock selection (session-only)"
lockKey = "Lock button"
orderDesc = "Bring forward / send backward (one step)"
orderKey = "Toolbar ↑ ↓"
[pdfTextEditor.help.clipboard]
copyDesc = "Copy selected text"
copyKey = "Ctrl+C"
cutDesc = "Cut selected (copy + delete)"
cutKey = "Ctrl+X"
heading = "Clipboard"
pasteDesc = "Paste clipboard text as new run"
pasteKey = "Ctrl+V"
pastePlainDesc = "Paste as plain text"
pastePlainKey = "Ctrl+Shift+V"
[pdfTextEditor.help.document]
escDesc = "Clear selection / close find / close help"
escKey = "Esc"
heading = "Document"
helpDesc = "This help"
helpKey = "? / F1"
saveDesc = "Save to your workspace"
saveKey = "Ctrl+S"
[pdfTextEditor.help.editing]
clickDesc = "Edit text"
clickKey = "Click"
deleteDesc = "Remove selected"
deleteKey = "Delete"
duplicateDesc = "Duplicate selected"
duplicateKey = "Ctrl+D"
groupDesc = "Group selected runs (Group button)"
groupKey = "Ctrl+M"
heading = "Editing"
marqueeDesc = "Marquee multi-select"
marqueeKey = "Ctrl+Shift+drag"
moveDesc = "Move text run"
moveKey = "Ctrl+Click + drag"
selectAllDesc = "Select all"
selectAllKey = "Ctrl+A"
shiftClickDesc = "Add / remove a run from selection"
shiftClickKey = "Ctrl+Click / Shift+Click"
undoRedoDesc = "Undo / Redo"
undoRedoKey = "Ctrl+Z / Ctrl+Y"
ungroupDesc = "Ungroup paragraph: select it, click Ungroup"
ungroupKey = "-"
[pdfTextEditor.help.find]
enterFindDesc = "Next match"
enterFindKey = "Enter (in find)"
enterReplaceDesc = "Replace one (Shift = Replace All)"
enterReplaceKey = "Enter (in replace)"
heading = "Find & Replace"
nextDesc = "Next match (Shift = previous)"
nextKey = "F3 / Ctrl+G"
openDesc = "Open find bar (and replace)"
openKey = "Ctrl+F"
[pdfTextEditor.help.formatting]
caseDesc = "Change case (upper/lower/title/sentence)"
caseKey = "Toolbar case (Aa)"
colourDesc = "Change fill colour"
colourKey = "Toolbar colour"
fontFamilyDesc = "Swap to base-14 font"
fontFamilyKey = "Toolbar font family"
fontSizeDesc = "Change font size"
fontSizeKey = "Toolbar font size"
heading = "Text formatting"
italicDesc = "Italic"
italicKey = "Toolbar I"
[pdfTextEditor.help.image]
flipDesc = "Flip horizontally or vertically"
flipKey = "Toolbar flip"
heading = "Image"
moveDesc = "Move image"
moveKey = "Drag"
resizeDesc = "Resize image"
resizeKey = "Corner drag"
rotateDesc = "Rotate 90° clockwise or counter-clockwise"
rotateKey = "Toolbar rotate"
[pdfTextEditor.help.navigation]
firstLastDesc = "First / last page"
firstLastKey = "Ctrl+Home / Ctrl+End"
heading = "Navigation"
pageDesc = "Next / previous page"
pageKey = "PageDown / PageUp"
toolbarZoomDesc = "Manual zoom + Fit to width"
toolbarZoomKey = "Toolbar zoom"
zoomDesc = "Zoom in / out"
zoomKey = "Ctrl+Wheel"
[pdfTextEditor.inspector]
document = "Document"
fontEmbedded = "Embedded font · a character it lacks falls back to Helvetica."
fontGap = "{{name}} · missing {{glyphs}} - typing those falls back to Helvetica."
geometry = "Position & size"
height = "Height"
heightHint = "A text box's height follows its type size and line count."
image = "Image"
images = "Images"
manyImages = "{{count}} images"
manyText = "Text · {{count}} boxes"
mixed = "{{count}} objects"
multiGeometry = "Select a single object to edit its position and size."
nothingSelected = "Nothing selected"
nothingSelectedHint = "Click any text or image on the page to edit it here."
oneImage = "Image"
oneText = "Text"
pages = "Pages"
tabDocument = "Document"
tabSelected = "Selected"
textBoxes = "Text boxes"
width = "Width"
widthHint = "A text box's width follows its content and wrapping."
x = "X"
y = "Y"
[pdfTextEditor.password]
cancel = "Cancel"
confirm = "Reset and Change Mode"
title = "Confirm Mode Change"
warning = "Changing the text grouping mode will reset all unsaved changes. Are you sure you want to continue?"
incorrect = "Incorrect password - try again."
label = "Password"
open = "Open"
protected = "This PDF is password-protected."
protectedNamed = "\"{{fileName}}\" is password-protected."
title = "Password required"
[pdfTextEditor.options.advanced]
title = "Advanced Settings"
[pdfTextEditor.rulers]
guide = "Alignment guide at {{value}} {{unit}} - drag onto a ruler to remove"
hint = "Drag from a ruler to add an alignment guide"
horizontal = "Horizontal ruler"
unit = "pt"
vertical = "Vertical ruler"
[pdfTextEditor.options.autoScaleText]
description = "Automatically scales text horizontally to fit within its original bounding box when font rendering differs from PDF."
title = "Auto-scale text to fit boxes"
[pdfTextEditor.run]
lockedTitle = "Locked - use the Unlock button to edit"
[pdfTextEditor.options.forceSingleElement]
description = "When enabled, the editor exports each edited text box as one PDF text element to avoid overlapping glyphs or mixed fonts."
title = "Lock edited text to a single PDF element"
[pdfTextEditor.saveRisk]
cancel = "Cancel"
intro = "Saving the edited copy changes the file. That means:"
note = "Your edits are kept. The changes listed above are unavoidable when saving the edited copy."
saveAnyway = "Save anyway"
title = "Saving will change this PDF"
[pdfTextEditor.options.groupingMode]
autoDescription = "Automatically detects page type and groups text appropriately."
paragraphDescription = "Groups aligned lines into multi-line paragraph text boxes."
singleLineDescription = "Keeps each PDF text line as a separate text box."
title = "Text Grouping Mode"
[pdfTextEditor.settings]
advanced = "Advanced"
find = "Find in document"
view = "View"
[pdfTextEditor.pageType]
paragraph = "Paragraph page"
sparse = "Sparse text"
[pdfTextEditor.sidebar]
addImage = "Add image"
addText = "Add text"
clickPageToAddText = "Click page to add text"
document = "Document"
group = "Group"
groupingAuto = "Auto"
groupingAutoHint = "Groups equal-spaced lines into paragraphs. Changing this re-reads the document and clears undo history."
groupingLine = "Line"
groupTooltip = "Merge selected runs into one paragraph (Ctrl+M)"
groupTooltipDisabled = "Select 2+ runs to merge"
noFile = "No file loaded"
noFileHint = "Pick a PDF from the Files panel on the left, or drop one in. The editor will open it automatically."
opening = "Opening document..."
paragraph = "Paragraph"
rulers = "Rulers and guides"
textBoxWidth = "New text box width"
textGrouping = "Text grouping"
ungroup = "Ungroup"
ungroupTooltip = "Split this paragraph into one run per line"
ungroupTooltipDisabled = "Select a multi-line paragraph to ungroup"
widthGrow = "Grow"
widthGrowHint = "Grow widens a box as you type; Wrap keeps its width and flows onto new lines."
widthWrap = "Wrap"
[pdfTextEditor.stages]
processing = "Processing"
uploading = "Uploading"
[pdfTextEditor.spellcheck]
auto = "Automatic"
enable = "Check spelling as you type"
language = "Dictionary language"
[pdfTextEditor.stage]
loadingDocument = "Loading document"
loadingProgress = "Loading progress"
noDocument = "No document loaded."
pickPrompt = "Pick a PDF from the Files panel on the left to begin editing."
renderingPreview = "Rendering preview"
[pdfTextEditor.toolbar]
advancedColour = "Advanced colour"
advancedColourTooltip = "Advanced colour (glyph outline)"
alignBottom = "Align bottom"
alignCentre = "Align centre"
alignLabel = "Align · needs 2+ objects"
alignLeft = "Align left"
alignMiddle = "Align middle"
alignRight = "Align right"
alignTop = "Align top"
arrange = "Arrange"
bringForward = "Bring forward"
bringToFront = "Bring to front"
caseLower = "lowercase"
caseSentence = "Sentence case"
caseTitle = "Title Case"
caseUpper = "UPPERCASE"
changeCase = "Change case"
changeCaseTooltip = "Change case (text runs only)"
delete = "Delete selected"
deleteTooltip = "Delete (Del)"
distributeHorizontally = "Distribute horizontally"
distributeLabel = "Distribute · needs 3+ objects"
distributeVertically = "Distribute vertically"
editImageExternally = "Edit in another app"
flipHorizontal = "Flip horizontal"
flipVertical = "Flip vertical"
fontColour = "Font colour"
fontSize = "Font size"
italic = "Italic"
italicUnavailable = "This font has no italic version. Load your device fonts or pick another font family."
lock = "Lock selection"
lockTooltip = "Lock selection - prevents accidental edits"
order = "Order"
outlineColour = "Outline colour"
outlineWidth = "Outline width (0 = none)"
redo = "Redo"
redoTooltip = "Redo (Ctrl+Y)"
replaceImage = "Replace, keeping placement"
rotateLeft = "Rotate 90° left"
rotateRight = "Rotate 90° right"
sendBackward = "Send backward"
sendToBack = "Send to back"
undo = "Undo"
undoTooltip = "Undo (Ctrl+Z)"
unlock = "Unlock selection"
unlockTooltip = "Unlock selection - makes it editable again"
[pdfTextEditor.tooltip.alpha]
text = "This alpha viewer is still evolving-certain fonts, colors, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing."
@@ -6450,31 +6687,12 @@ title = "Preview Variance"
text = "This workspace focuses on editing text and repositioning embedded images. Complex page artwork, form widgets, and layered graphics are preserved for export but are not fully editable here."
title = "Text and Image Focus"
[pdfTextEditor.welcomeBanner]
bestFor = "Works Best With:"
bestFor1 = "Simple PDFs containing primarily text and images"
bestFor2 = "Documents with standard paragraph formatting"
bestFor3 = "Letters, essays, reports, and basic documents"
dontShowAgain = "Don't show again"
experimental = "This is an experimental feature in active development. Expect some instability and issues during use."
feedback = "This is an early access feature. Please report any issues you encounter to help us improve!"
gotIt = "Got it"
howItWorks = "This tool converts your PDF to an editable format where you can modify text content and reposition images. Changes are saved back as a new PDF."
issue1 = "Text color is not currently preserved (will be added soon)"
issue2 = "Paragraph mode has more alignment and spacing issues - Single Line mode recommended"
issue3 = "The preview display differs from the exported PDF - exported PDFs are closer to the original"
issue4 = "Rotated text alignment may need manual adjustment"
issue5 = "Transparency and layering effects may vary from original"
knownIssues = "Known Issues (Being Fixed):"
limitation1 = "Font rendering may differ slightly from the original PDF"
limitation2 = "Complex graphics, form fields, and annotations are preserved but not editable"
limitation3 = "Large files may take time to convert and process"
limitations = "Current Limitations:"
notIdealFor = "Not Ideal For:"
notIdealFor1 = "PDFs with special formatting like bullet points, tables, or multi-column layouts"
notIdealFor2 = "Magazines, brochures, or heavily designed documents"
notIdealFor3 = "Instruction manuals with complex layouts"
title = "Welcome to PDF Text Editor (Early Access)"
[pdfTextEditor.zoom]
fit = "Fit"
fitToWidth = "Fit to width"
in = "Zoom in"
out = "Zoom out"
reset = "Reset zoom to 100%"
[PDFToCSV]
header = "PDF to CSV"
@@ -6570,7 +6788,7 @@ popular = "Popular"
selectPlan = "Select Plan"
showComparison = "Compare All Features"
upgrade = "Upgrade"
withServer = "+ Server Plan"
withServer = "+ Team plan"
[plan.api]
large = "5,000 Credits"
@@ -6588,8 +6806,8 @@ highlight1 = "Custom pricing"
highlight2 = "Dedicated support"
highlight3 = "Latest features"
name = "Enterprise"
requiresServer = "Requires Server"
requiresServerMessage = "Please upgrade to the Server plan first before upgrading to Enterprise."
requiresServer = "Requires Team plan"
requiresServerMessage = "Please upgrade to the Team plan first before upgrading to Enterprise."
[plan.feature]
api = "API Access"
@@ -6614,10 +6832,10 @@ saml = "SAML"
secureLoginSupport = "Secure Login Support"
selfHostedDeployment = "Self-hosted deployment"
sso = "SSO"
unlimitedUsers = "Unlimited users"
upToFiveUsers = "Up to 5 users"
upToFiveUsersLowercase = "up to 5 users"
usageTracking = "Usage tracking"
usersIncluded = "100 users included"
usersLimitedToSeats = "Users limited to seats"
[plan.free]
@@ -6646,12 +6864,12 @@ saveWithAnnualBilling = "Save with annual billing"
selfHosted = "Self-hosted"
selfHostedOnInfrastructure = "Self-hosted on your infrastructure"
ssoOAuth = "SSO (OAuth2/OIDC)"
unlimitedUsers = "Unlimited users"
upToFiveUsers = "Up to 5 users"
usageTrackingPrometheus = "Usage tracking & Prometheus"
usersIncluded = "100 users included"
[plan.licenseWarning]
body = "You have {{total}} users but the free tier only supports {{limit}} per server. Upgrade to keep Stirling PDF running smoothly."
body = "You have {{total}} users but the free tier only supports {{limit}}. Upgrade to keep Stirling PDF running smoothly."
cta = "See plans"
overLimit = "more than {{limit}}"
title = "Free self-hosted limit reached"
@@ -6674,7 +6892,7 @@ title = "You're on a Roll!"
[plan.static]
activateLicense = "Activate Your License"
contactToUpgrade = "Contact us to upgrade or customize your plan"
getLicense = "Get Server License"
getLicense = "Get the Team plan"
monthlyBilling = "Monthly Billing"
selectPeriod = "Select Billing Period"
upgradeToEnterprise = "Upgrade to Enterprise"
@@ -6695,6 +6913,10 @@ keyDescription = "Paste the license key from your email"
success = "License Activated!"
successMessage = "Your license has been successfully activated. You can now close this window."
[plan.team]
maxUsers = "100 users"
name = "Team"
[policies.activity]
outputsUnavailable = "Policy outputs are no longer available to download."
partialOutputsUnavailable = "Some policy outputs are no longer available to download."
@@ -7000,16 +7222,16 @@ subtitle = "Deploy anywhere, for your whole team."
title = "Free PDF Editors"
[portal.billing.freePlan]
anywhere = "Web, desktop & self-hosted"
checkoutErrorTitle = "Couldn't start checkout"
currentPlan = "Current plan"
everyPdfTool = "Every PDF tool"
freeForever = "Free forever"
noTeamResolved = "No team is resolved on your wallet yet — refresh and try again."
ownerOnly = "Only the team owner can switch on the Processor plan."
payInvoice = "Pay invoice to complete"
planName = "Editor"
ssoIncluded = "SSO included"
switchOnProcessor = "Switch on the Processor →"
unlimitedUsers = "Unlimited users"
viewQuote = "View quote"
[portal.billing.invoices]
@@ -10444,18 +10666,6 @@ memberCount_one = "{{count}} team member"
memberCount_other = "{{count}} team members"
memberCount_zero = "no team members"
[settings.planBilling.tier]
enterprise = "Enterprise"
enterpriseDescription = "Custom enterprise features and support"
free = "Free"
freeDescription = "50 credits per month"
team = "Team"
teamBadge = "Team"
teamDescription = "500 credits/month included, automatic overage billing for uninterrupted service"
teamTooltipCredits = "Team plan includes {{credits}} credits/month."
teamTooltipFineprint = "Only pay for what you use beyond included credits."
teamTooltipOverage = "Automatic overage billing at {{price}}/credit ensures uninterrupted service."
[settings.planBilling.trial]
daysRemaining = "{{days}} days remaining"
daysRemainingFull = "Your trial ends in {{days}} days"
@@ -11414,9 +11624,9 @@ urgent = "Urgent"
attentionBody = "Your admin needs to sign in to see more info. Please contact them immediately."
attentionBodyAdmin = "Review the license requirements to keep this server compliant."
attentionTitle = "This server needs admin attention"
message = "Get the most out of Stirling PDF with unlimited users and advanced features"
message = "Get the most out of Stirling PDF with 100 users, SSO, and advanced features"
seeInfo = "See info"
title = "Upgrade to Server Plan"
title = "Upgrade to the Team plan"
upgradeButton = "Upgrade Now"
[URLToPDF]
@@ -0,0 +1,22 @@
import apiClient from "@app/services/apiClient";
export async function fetchAdminSection<T>(sectionName: string): Promise<T> {
const response = await apiClient.get<T>(
`/api/v1/admin/settings/section/${sectionName}`,
);
return (response.data ?? {}) as T;
}
export async function putAdminSection(
sectionName: string,
delta: unknown,
): Promise<void> {
await apiClient.put(`/api/v1/admin/settings/section/${sectionName}`, delta);
}
/** Flat dotted-path settings, for sections that write outside their own block. */
export async function putAdminSettings(
settings: Record<string, unknown>,
): Promise<void> {
await apiClient.put("/api/v1/admin/settings", { settings });
}
@@ -604,7 +604,10 @@ const FileEditorThumbnail = ({
{/* Badges — top-left: version, pin, ownership, encrypted */}
<div className={styles.thumbBadges}>
<span className={styles.versionBadgeThumb}>
<span
className={styles.versionBadgeThumb}
data-testid="file-version-badge"
>
v{file.versionNumber}
</span>
{isPinned && (
@@ -20,8 +20,8 @@ export default function ServerLicenseSlide({
totalUsers != null ? totalUsers.toLocaleString() : null;
const overLimitUserCopy = formattedTotalUsers ?? `more than ${freeTierLimit}`;
const title = isOverLimit
? i18n.t("onboarding.serverLicense.overLimitTitle", "Server License Needed")
: i18n.t("onboarding.serverLicense.freeTitle", "Server License");
? i18n.t("onboarding.serverLicense.overLimitTitle", "Team plan needed")
: i18n.t("onboarding.serverLicense.freeTitle", "Team plan");
const key = isOverLimit ? "server-license-over-limit" : "server-license";
const overLimitBody = (
@@ -31,7 +31,7 @@ export default function ServerLicenseSlide({
components={{
strong: <strong />,
}}
defaults="Our licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. You have <strong>{{overLimitUserCopy}}</strong> Stirling users. To continue uninterrupted, upgrade to the Stirling Server plan - <strong>unlimited seats</strong>, PDF text editing, and full admin control for $99/server/mo."
defaults="Our licensing permits up to <strong>{{freeTierLimit}}</strong> users for free. You have <strong>{{overLimitUserCopy}}</strong> Stirling users. To continue uninterrupted, upgrade to the Stirling Team plan - <strong>100 users</strong>, PDF text editing, and full admin control for $99/mo."
/>
);
@@ -42,7 +42,7 @@ export default function ServerLicenseSlide({
components={{
strong: <strong />,
}}
defaults="Our <strong>Open-Core</strong> licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. To scale uninterrupted, we recommend the Stirling Server plan - <strong>unlimited seats</strong> and <strong>SSO support</strong> for $99/server/mo."
defaults="Our <strong>Open-Core</strong> licensing permits up to <strong>{{freeTierLimit}}</strong> users for free. To scale uninterrupted, we recommend the Stirling Team plan - <strong>100 users</strong> and <strong>SSO support</strong> for $99/mo."
/>
);
@@ -760,14 +760,16 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
await onUploadFiles(files);
} else {
await addFiles(files);
if (!isMultiTool) {
// A tool that pinned its own workbench surface owns it - switching to
// the viewer here strands the upload outside the tool being used.
if (!isMultiTool && !currentWorkbench.startsWith("custom:")) {
navActions.setWorkbench(
files.length === 1 ? "viewer" : "fileEditor",
);
}
}
},
[addFiles, navActions, isMultiTool, onUploadFiles],
[addFiles, navActions, isMultiTool, onUploadFiles, currentWorkbench],
);
const handleNativeFilePick = useCallback(
@@ -402,14 +402,20 @@ export default function WorkbenchBar({
);
// View options
// Tools that own a custom workbench ship their own canvas.
const ownsCustomWorkbenchAsDefault = selectedTool === "pdfTextEditor";
const viewOptions: ViewOption[] = [
...(ownsCustomWorkbenchAsDefault
? []
: [
{
value: "viewer" as WorkbenchType,
label: t("workbenchBar.viewer", "Viewer"),
icon: <InsertDriveFileOutlinedIcon fontSize="small" />,
},
]),
{
value: "viewer",
label: t("workbenchBar.viewer", "Viewer"),
icon: <InsertDriveFileOutlinedIcon fontSize="small" />,
},
{
value: "fileEditor",
value: "fileEditor" as WorkbenchType,
label: t("workbenchBar.activeFiles", "Active Files"),
icon: <FolderOutlinedIcon fontSize="small" />,
},
@@ -70,7 +70,7 @@ export const CertificateSelector: React.FC<CertificateSelectorProps> = ({
return (
<Stack gap="md">
{/* Managed certificate options — server plan only */}
{/* Managed certificate options — Team plan only */}
{isServerPlan && (
<Radio.Group
value={certType}
@@ -1,372 +0,0 @@
import React, { useMemo, useState } from "react";
import {
Badge,
Box,
Code,
Collapse,
Divider,
Flex,
Group,
List,
Paper,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import WarningIcon from "@mui/icons-material/Warning";
import ErrorIcon from "@mui/icons-material/Error";
import InfoIcon from "@mui/icons-material/Info";
import FontDownloadIcon from "@mui/icons-material/FontDownload";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
import { PdfJsonDocument } from "@app/tools/pdfTextEditor/pdfTextEditorTypes";
import {
analyzeDocumentFonts,
DocumentFontAnalysis,
FontAnalysis,
getFontStatusColor,
getFontStatusDescription,
} from "@app/tools/pdfTextEditor/fontAnalysis";
import LocalIcon from "@app/components/shared/LocalIcon";
import { Tooltip as CustomTooltip } from "@app/components/shared/Tooltip";
interface FontStatusPanelProps {
document: PdfJsonDocument | null;
pageIndex?: number;
isCollapsed?: boolean;
onCollapsedChange?: (collapsed: boolean) => void;
}
const FontStatusBadge = ({ analysis }: { analysis: FontAnalysis }) => {
const color = getFontStatusColor(analysis.status);
const description = getFontStatusDescription(analysis.status);
const icon = useMemo(() => {
switch (analysis.status) {
case "perfect":
return <CheckCircleIcon sx={{ fontSize: 14 }} />;
case "embedded-subset":
return <InfoIcon sx={{ fontSize: 14 }} />;
case "system-fallback":
return <WarningIcon sx={{ fontSize: 14 }} />;
case "missing":
return <ErrorIcon sx={{ fontSize: 14 }} />;
default:
return <InfoIcon sx={{ fontSize: 14 }} />;
}
}, [analysis.status]);
return (
<Tooltip label={description} position="top" withArrow>
<Badge
size="xs"
color={color}
variant="light"
leftSection={icon}
style={{ cursor: "help" }}
>
{analysis.status.replace("-", " ")}
</Badge>
</Tooltip>
);
};
const FontDetailItem = ({ analysis }: { analysis: FontAnalysis }) => {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
return (
<Paper
withBorder
px="sm"
py="md"
style={{ cursor: "pointer" }}
onClick={() => setExpanded(!expanded)}
>
<Stack gap={4}>
<Flex align="center" justify="space-between" wrap="nowrap">
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<FontDownloadIcon sx={{ fontSize: 16, flexShrink: 0 }} />
<CustomTooltip
sidebarTooltip={false}
content={analysis.baseName}
position="top"
>
<Text
size="xs"
fw={500}
lineClamp={1}
style={{ flex: 1, minWidth: 0 }}
>
{analysis.baseName}
</Text>
</CustomTooltip>
{analysis.isSubset && (
<Badge
size="xs"
color="gray"
variant="outline"
style={{ flexShrink: 0 }}
>
subset
</Badge>
)}
</Group>
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}>
<FontStatusBadge analysis={analysis} />
{expanded ? (
<ExpandLessIcon sx={{ fontSize: 16 }} />
) : (
<ExpandMoreIcon sx={{ fontSize: 16 }} />
)}
</Group>
</Flex>
<Collapse in={expanded}>
<Stack gap={4} mt={4}>
{/* Font Details */}
<Box>
<Text size="xs" c="dimmed" mb={2}>
{t("pdfTextEditor.fontAnalysis.details", "Font Details")}:
</Text>
<Stack gap={2}>
<Group gap={4}>
<Text size="xs" c="dimmed">
{t("pdfTextEditor.fontAnalysis.embedded", "Embedded")}:
</Text>
<Code style={{ fontSize: "0.65rem", padding: "0 4px" }}>
{analysis.embedded ? "Yes" : "No"}
</Code>
</Group>
{analysis.subtype && (
<Group gap={4}>
<Text size="xs" c="dimmed">
{t("pdfTextEditor.fontAnalysis.type", "Type")}:
</Text>
<Code style={{ fontSize: "0.65rem", padding: "0 4px" }}>
{analysis.subtype}
</Code>
</Group>
)}
{analysis.webFormat && (
<Group gap={4}>
<Text size="xs" c="dimmed">
{t("pdfTextEditor.fontAnalysis.webFormat", "Web Format")}:
</Text>
<Code style={{ fontSize: "0.65rem", padding: "0 4px" }}>
{analysis.webFormat}
</Code>
</Group>
)}
</Stack>
</Box>
{/* Warnings */}
{analysis.warnings.length > 0 && (
<Box>
<Text size="xs" c="var(--color-amber-dark)" fw={500}>
{t("pdfTextEditor.fontAnalysis.warnings", "Warnings")}:
</Text>
<List size="xs" spacing={2} withPadding>
{analysis.warnings.map((warning, index) => (
<List.Item key={index}>
<Text size="xs">{warning}</Text>
</List.Item>
))}
</List>
</Box>
)}
{/* Suggestions */}
{analysis.suggestions.length > 0 && (
<Box>
<Text size="xs" c="var(--c-accent-text)" fw={500}>
{t("pdfTextEditor.fontAnalysis.suggestions", "Notes")}:
</Text>
<List size="xs" spacing={2} withPadding>
{analysis.suggestions.map((suggestion, index) => (
<List.Item key={index}>
<Text size="xs">{suggestion}</Text>
</List.Item>
))}
</List>
</Box>
)}
</Stack>
</Collapse>
</Stack>
</Paper>
);
};
const FontStatusPanel: React.FC<FontStatusPanelProps> = ({
document,
pageIndex,
isCollapsed = false,
onCollapsedChange,
}) => {
const { t } = useTranslation();
const fontAnalysis: DocumentFontAnalysis = useMemo(
() => analyzeDocumentFonts(document, pageIndex),
[document, pageIndex],
);
const { canReproducePerfectly, hasWarnings, summary, fonts } = fontAnalysis;
// Early return AFTER all hooks are declared
if (!document || fontAnalysis.fonts.length === 0) {
return null;
}
const statusColor = canReproducePerfectly
? "green"
: hasWarnings
? "yellow"
: "blue";
const pageLabel =
pageIndex !== undefined
? t("pdfTextEditor.fontAnalysis.currentPageFonts", "Fonts on this page")
: t("pdfTextEditor.fontAnalysis.allFonts", "All fonts");
return (
<div>
<div
style={{
padding: "0.5rem",
opacity: isCollapsed ? 0.8 : 1,
color: isCollapsed ? "var(--mantine-color-dimmed)" : "inherit",
transition: "opacity 0.2s ease, color 0.2s ease",
}}
>
{/* Header - matches ToolStep style */}
<Flex
align="center"
justify="space-between"
mb={isCollapsed ? 0 : "sm"}
style={{ cursor: "pointer" }}
onClick={() => onCollapsedChange?.(!isCollapsed)}
>
<Flex align="center" gap="xs">
<Text fw={500} size="sm">
{pageLabel}
</Text>
<Badge size="xs" color={statusColor} variant="dot">
{fonts.length}
</Badge>
</Flex>
{isCollapsed ? (
<LocalIcon
icon="chevron-right-rounded"
width="1.2rem"
height="1.2rem"
style={{
color: "var(--mantine-color-dimmed)",
}}
/>
) : (
<LocalIcon
icon="expand-more-rounded"
width="1.2rem"
height="1.2rem"
style={{
color: "var(--mantine-color-dimmed)",
}}
/>
)}
</Flex>
{/* Content */}
{!isCollapsed && (
<Stack gap="xs" pl="sm">
{/* Overall Status Message */}
<Text size="xs" c="dimmed">
{canReproducePerfectly
? t(
"pdfTextEditor.fontAnalysis.perfectMessage",
"All fonts can be reproduced perfectly.",
)
: hasWarnings
? t(
"pdfTextEditor.fontAnalysis.warningMessage",
"Some fonts may not render correctly.",
)
: t(
"pdfTextEditor.fontAnalysis.infoMessage",
"Font reproduction information available.",
)}
</Text>
{/* Summary Statistics */}
<Group gap={4} wrap="wrap">
{summary.perfect > 0 && (
<Badge
size="xs"
color="green"
variant="light"
leftSection={<CheckCircleIcon sx={{ fontSize: 12 }} />}
>
{summary.perfect}{" "}
{t("pdfTextEditor.fontAnalysis.perfect", "perfect")}
</Badge>
)}
{summary.embeddedSubset > 0 && (
<Badge
size="xs"
color="blue"
variant="light"
leftSection={<InfoIcon sx={{ fontSize: 12 }} />}
>
{summary.embeddedSubset}{" "}
{t("pdfTextEditor.fontAnalysis.subset", "subset")}
</Badge>
)}
{summary.systemFallback > 0 && (
<Badge
size="xs"
color="yellow"
variant="light"
leftSection={<WarningIcon sx={{ fontSize: 12 }} />}
>
{summary.systemFallback}{" "}
{t("pdfTextEditor.fontAnalysis.fallback", "fallback")}
</Badge>
)}
{summary.missing > 0 && (
<Badge
size="xs"
color="red"
variant="light"
leftSection={<ErrorIcon sx={{ fontSize: 12 }} />}
>
{summary.missing}{" "}
{t("pdfTextEditor.fontAnalysis.missing", "missing")}
</Badge>
)}
</Group>
{/* Font List */}
<Stack gap={4} mt="xs">
{fonts.map((font, index) => (
<FontDetailItem
key={`${font.fontId}-${index}`}
analysis={font}
/>
))}
</Stack>
</Stack>
)}
</div>
<Divider
style={{ color: "#E2E8F0", marginLeft: "1rem", marginRight: "-0.5rem" }}
/>
</div>
);
};
export default FontStatusPanel;
@@ -1,437 +0,0 @@
import React, { useCallback, useMemo, useState } from "react";
import {
Badge,
Divider,
Flex,
Group,
Menu,
Modal,
ScrollArea,
Stack,
Switch,
Text,
} from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { SegmentedControl } from "@app/ui/SegmentedControl";
import { useTranslation } from "react-i18next";
import AutorenewIcon from "@mui/icons-material/Autorenew";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
import FileDownloadIcon from "@mui/icons-material/FileDownloadOutlined";
import {
PdfTextEditorViewData,
TextGroup,
} from "@app/tools/pdfTextEditor/pdfTextEditorTypes";
import { pageDimensions } from "@app/tools/pdfTextEditor/pdfTextEditorUtils";
import FontStatusPanel from "@app/components/tools/pdfTextEditor/FontStatusPanel";
import ToolStep from "@app/components/tools/shared/ToolStep";
import { usePdfTextEditorTips } from "@app/components/tooltips/usePdfTextEditorTips";
import { Tooltip } from "@app/components/shared/Tooltip";
import LocalIcon from "@app/components/shared/LocalIcon";
type GroupingMode = "auto" | "paragraph" | "singleLine";
interface PdfTextEditorSidebarProps {
data: PdfTextEditorViewData;
}
// Analyze page content to determine if it's paragraph-heavy
const analyzePageContentType = (
groups: TextGroup[],
pageWidth: number,
): boolean => {
if (groups.length < 3) {
return false;
}
const widths = groups.map((g) => Math.max(g.bounds.right - g.bounds.left, 1));
const avgWidth = widths.reduce((sum, w) => sum + w, 0) / widths.length;
const stdDev = Math.sqrt(
widths.reduce((sum, w) => sum + Math.pow(w - avgWidth, 2), 0) /
widths.length,
);
const coefficientOfVariation = avgWidth > 0 ? stdDev / avgWidth : 0;
const fullWidthRatio =
widths.filter((w) => w > pageWidth * 0.65).length / widths.length;
const criterion1 = groups.length >= 3;
const criterion2 = avgWidth > pageWidth * 0.3;
const criterion3 = coefficientOfVariation > 0.5 || fullWidthRatio > 0.6;
return criterion1 && criterion2 && criterion3;
};
const PdfTextEditorSidebar = ({ data }: PdfTextEditorSidebarProps) => {
const { t } = useTranslation();
const [pendingModeChange, setPendingModeChange] =
useState<GroupingMode | null>(null);
const [advancedSettingsCollapsed, setAdvancedSettingsCollapsed] =
useState(false);
const [fontsCollapsed, setFontsCollapsed] = useState(false);
const pdfTextEditorTips = usePdfTextEditorTips();
const {
document: pdfDocument,
groupsByPage,
hasDocument,
hasChanges,
fileName,
isGeneratingPdf,
isSavingToWorkbench,
isConverting,
forceSingleTextElement,
groupingMode: externalGroupingMode,
autoScaleText,
selectedPage,
onReset,
onGeneratePdf,
onSaveToWorkbench,
onForceSingleTextElementChange,
onGroupingModeChange,
onAutoScaleTextChange,
} = data;
// Get page dimensions
const pages = pdfDocument?.pages ?? [];
const currentPage = pages[selectedPage] ?? null;
const { width: pageWidth } = pageDimensions(currentPage);
const pageGroups = groupsByPage[selectedPage] ?? [];
// Detect if current page contains paragraph-heavy content
const isParagraphPage = useMemo(() => {
return analyzePageContentType(pageGroups, pageWidth);
}, [pageGroups, pageWidth]);
const handleModeChangeRequest = useCallback(
(newMode: GroupingMode) => {
if (hasChanges && newMode !== externalGroupingMode) {
setPendingModeChange(newMode);
} else {
onGroupingModeChange(newMode);
}
},
[hasChanges, externalGroupingMode, onGroupingModeChange],
);
const handleConfirmModeChange = useCallback(() => {
if (pendingModeChange) {
onGroupingModeChange(pendingModeChange);
setPendingModeChange(null);
}
}, [pendingModeChange, onGroupingModeChange]);
const handleCancelModeChange = useCallback(() => {
setPendingModeChange(null);
}, []);
return (
<>
<Stack style={{ height: "100%", display: "flex" }} gap={0}>
<ScrollArea style={{ flex: 1 }} offsetScrollbars>
<Stack gap="md">
<Stack gap="xs" pl="md" pr={0} pt="md">
{/* Title row with ALPHA badge and info tooltip */}
<Flex align="center" justify="space-between">
<Flex align="center" gap="xs">
<Text fw={600} size="sm">
{t("pdfTextEditor.title", "PDF Text Editor")}
</Text>
<Badge size="xs" variant="light" color="orange">
{t("toolPanel.alpha", "Alpha")}
</Badge>
</Flex>
<Tooltip
sidebarTooltip={true}
tips={pdfTextEditorTips.tips}
header={pdfTextEditorTips.header}
pinOnClick
>
<ActionIcon
variant="tertiary"
size="sm"
aria-label={t("pdfTextEditor.title", "PDF Text Editor")}
>
<LocalIcon
icon="info-outline-rounded"
width="1.25rem"
height="1.25rem"
/>
</ActionIcon>
</Tooltip>
</Flex>
{fileName && (
<Text size="sm" c="dimmed">
{t("pdfTextEditor.currentFile", "Current file: {{name}}", {
name: fileName,
})}
</Text>
)}
</Stack>
<ToolStep
title={t(
"pdfTextEditor.options.advanced.title",
"Advanced Settings",
)}
isCollapsed={advancedSettingsCollapsed}
onCollapsedClick={() =>
setAdvancedSettingsCollapsed(!advancedSettingsCollapsed)
}
>
<Stack gap="md">
<Divider />
<Group justify="space-between" align="center">
<Group
gap={4}
align="center"
style={{ flex: 1, minWidth: 0 }}
>
<Tooltip
sidebarTooltip={false}
content={t(
"pdfTextEditor.options.autoScaleText.description",
"Automatically scales text horizontally to fit within its original bounding box when font rendering differs from PDF.",
)}
position="top"
>
<ActionIcon
variant="tertiary"
size="sm"
aria-label={t(
"pdfTextEditor.options.autoScaleText.title",
"Auto-scale text to fit boxes",
)}
style={{ flexShrink: 0 }}
>
<InfoOutlinedIcon fontSize="small" />
</ActionIcon>
</Tooltip>
<Text fw={500} size="sm" style={{ flex: 1 }}>
{t(
"pdfTextEditor.options.autoScaleText.title",
"Auto-scale text to fit boxes",
)}
</Text>
</Group>
<Switch
size="md"
checked={autoScaleText}
onChange={(event) =>
onAutoScaleTextChange(event.currentTarget.checked)
}
/>
</Group>
<Divider />
<Stack gap="xs">
<Group gap={4} align="center">
<Text fw={500} size="sm">
{t(
"pdfTextEditor.options.groupingMode.title",
"Text Grouping Mode",
)}
</Text>
{externalGroupingMode === "auto" && isParagraphPage && (
<Badge
size="xs"
color="blue"
variant="light"
key={`para-${selectedPage}`}
>
{t(
"pdfTextEditor.pageType.paragraph",
"Paragraph page",
)}
</Badge>
)}
{externalGroupingMode === "auto" &&
!isParagraphPage &&
hasDocument && (
<Badge
size="xs"
color="gray"
variant="light"
key={`sparse-${selectedPage}`}
>
{t("pdfTextEditor.pageType.sparse", "Sparse text")}
</Badge>
)}
</Group>
<Text size="xs" c="dimmed">
{externalGroupingMode === "auto"
? t(
"pdfTextEditor.options.groupingMode.autoDescription",
"Automatically detects page type and groups text appropriately.",
)
: externalGroupingMode === "paragraph"
? t(
"pdfTextEditor.options.groupingMode.paragraphDescription",
"Groups aligned lines into multi-line paragraph text boxes.",
)
: t(
"pdfTextEditor.options.groupingMode.singleLineDescription",
"Keeps each PDF text line as a separate text box.",
)}
</Text>
<SegmentedControl
value={externalGroupingMode}
onChange={(value) => handleModeChangeRequest(value)}
options={[
{
label: t("pdfTextEditor.groupingMode.auto", "Auto"),
value: "auto",
},
{
label: t(
"pdfTextEditor.groupingMode.paragraph",
"Paragraph",
),
value: "paragraph",
},
{
label: t(
"pdfTextEditor.groupingMode.singleLine",
"Single Line",
),
value: "singleLine",
},
]}
fullWidth
/>
</Stack>
<Divider />
<Group justify="space-between" align="center">
<Group
gap={4}
align="center"
style={{ flex: 1, minWidth: 0 }}
>
<Tooltip
sidebarTooltip={false}
content={t(
"pdfTextEditor.options.forceSingleElement.description",
"When enabled, the editor exports each edited text box as one PDF text element to avoid overlapping glyphs or mixed fonts.",
)}
position="top"
>
<ActionIcon
variant="tertiary"
size="sm"
aria-label={t(
"pdfTextEditor.options.forceSingleElement.title",
"Lock edited text to a single PDF element",
)}
style={{ flexShrink: 0 }}
>
<InfoOutlinedIcon fontSize="small" />
</ActionIcon>
</Tooltip>
<Text fw={500} size="sm" style={{ flex: 1 }}>
{t(
"pdfTextEditor.options.forceSingleElement.title",
"Lock edited text to a single PDF element",
)}
</Text>
</Group>
<Switch
size="md"
checked={forceSingleTextElement}
onChange={(event) =>
onForceSingleTextElementChange(
event.currentTarget.checked,
)
}
/>
</Group>
</Stack>
</ToolStep>
{hasDocument && (
<FontStatusPanel
document={pdfDocument}
pageIndex={selectedPage}
isCollapsed={fontsCollapsed}
onCollapsedChange={setFontsCollapsed}
/>
)}
</Stack>
</ScrollArea>
<Group gap="xs" wrap="nowrap" p="md">
<Button
onClick={onSaveToWorkbench}
loading={isSavingToWorkbench}
disabled={!hasDocument || !hasChanges || isConverting}
style={{ flex: 1 }}
>
{t("pdfTextEditor.actions.applyChanges", "Apply Changes")}
</Button>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
variant="secondary"
size="lg"
disabled={!hasDocument || isConverting}
aria-label={t(
"pdfTextEditor.actions.moreOptions",
"More options",
)}
>
<MoreHorizIcon fontSize="small" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<FileDownloadIcon fontSize="small" />}
onClick={() => onGeneratePdf()}
disabled={!hasChanges || isGeneratingPdf}
>
{t("pdfTextEditor.actions.downloadCopy", "Download Copy")}
</Menu.Item>
<Menu.Item
leftSection={<AutorenewIcon fontSize="small" />}
onClick={onReset}
color="red"
>
{t("pdfTextEditor.actions.reset", "Reset Changes")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Stack>
{/* Mode Change Confirmation Modal */}
<Modal
opened={pendingModeChange !== null}
onClose={handleCancelModeChange}
title={t("pdfTextEditor.modeChange.title", "Confirm Mode Change")}
centered
>
<Stack gap="md">
<Text>
{t(
"pdfTextEditor.modeChange.warning",
"Changing the text grouping mode will reset all unsaved changes. Are you sure you want to continue?",
)}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="secondary" onClick={handleCancelModeChange}>
{t("pdfTextEditor.modeChange.cancel", "Cancel")}
</Button>
<Button accent="danger" onClick={handleConfirmModeChange}>
{t("pdfTextEditor.modeChange.confirm", "Reset and Change Mode")}
</Button>
</Group>
</Stack>
</Modal>
</>
);
};
export default PdfTextEditorSidebar;
File diff suppressed because it is too large Load Diff
@@ -109,7 +109,6 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
synonyms: getSynonyms(t, "pdfTextEditor"),
supportsAutomate: false,
automationSettings: null,
versionStatus: "alpha",
},
multiTool: {
icon: (
+12 -9
View File
@@ -1,4 +1,4 @@
import { readFileSync, readdirSync, statSync } from "fs";
import { readFileSync, readdirSync } from "fs";
import { join, extname } from "path";
import { fileURLToPath } from "url";
import { describe, it, expect } from "vitest";
@@ -18,17 +18,19 @@ function parseEnvKeys(content: string): Set<string> {
return keys;
}
// `withFileTypes` answers directory-or-file from the directory read itself;
// a statSync per entry made this walk of the whole tree exceed the timeout.
function collectSourceFiles(dir: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir)) {
const fullPath = join(dir, entry);
const stat = statSync(fullPath);
if (stat.isDirectory() && entry !== "node_modules" && entry !== "assets") {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const name = entry.name;
const fullPath = join(dir, name);
if (entry.isDirectory() && name !== "node_modules" && name !== "assets") {
files.push(...collectSourceFiles(fullPath));
} else if (
stat.isFile() &&
(extname(entry) === ".ts" || extname(entry) === ".tsx") &&
!entry.endsWith(".d.ts")
entry.isFile() &&
(extname(name) === ".ts" || extname(name) === ".tsx") &&
!name.endsWith(".d.ts")
) {
files.push(fullPath);
}
@@ -73,5 +75,6 @@ describe("env vars", () => {
missing,
`Missing from 'frontend/.env*' files: ${missing.join(", ")}`,
).toHaveLength(0);
});
// Reads every source file, so the budget is I/O, not the assertion.
}, 30_000);
});
@@ -0,0 +1,313 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { qk } from "@app/query/keys";
import {
fetchAdminSection,
putAdminSection,
putAdminSettings,
} from "@app/api/adminSettings";
vi.mock("@app/api/adminSettings", () => ({
fetchAdminSection: vi.fn(),
putAdminSection: vi.fn(),
putAdminSettings: vi.fn(),
}));
const mockFetch = vi.mocked(fetchAdminSection);
const mockPutSection = vi.mocked(putAdminSection);
const mockPutSettings = vi.mocked(putAdminSettings);
function makeWrapper() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
}
describe("useAdminSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetch.mockResolvedValue({ appName: "Stirling" });
mockPutSection.mockResolvedValue(undefined);
mockPutSettings.mockResolvedValue(undefined);
});
it("loads the section and seeds the editable draft", async () => {
const { result } = renderHook(
() => useAdminSettings({ sectionName: "general" }),
{ wrapper: makeWrapper() },
);
expect(result.current.loading).toBe(true);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.settings).toEqual({ appName: "Stirling" });
expect(mockFetch).toHaveBeenCalledWith("general");
});
it("shares one fetch between sections reading the same block", async () => {
const { result } = renderHook(
() => ({
a: useAdminSettings({ sectionName: "aiEngine" }),
b: useAdminSettings({ sectionName: "aiEngine" }),
c: useAdminSettings({ sectionName: "aiEngine" }),
}),
{ wrapper: makeWrapper() },
);
await waitFor(() => expect(result.current.a.loading).toBe(false));
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("serves a reopened tab from cache within the stale window", async () => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 30_000 } },
});
const shared = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
for (let i = 0; i < 4; i++) {
const tab = renderHook(
() => useAdminSettings({ sectionName: "aiEngine" }),
{ wrapper: shared },
);
await waitFor(() => expect(tab.result.current.loading).toBe(false));
tab.unmount();
}
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("keeps sections with different blocks apart", async () => {
const { result } = renderHook(
() => ({
a: useAdminSettings({ sectionName: "general" }),
b: useAdminSettings({ sectionName: "security" }),
}),
{ wrapper: makeWrapper() },
);
await waitFor(() => expect(result.current.a.loading).toBe(false));
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch).toHaveBeenCalledWith("general");
expect(mockFetch).toHaveBeenCalledWith("security");
});
it("does not fetch while disabled, and reports itself unloaded", async () => {
const { result } = renderHook(
() => useAdminSettings({ sectionName: "general", enabled: false }),
{
wrapper: makeWrapper(),
},
);
expect(mockFetch).not.toHaveBeenCalled();
// Sections gate their render on this; false would show an empty form.
expect(result.current.loading).toBe(true);
});
it("fetches when the gate opens", async () => {
const { result, rerender } = renderHook(
({ on }: { on: boolean }) =>
useAdminSettings({ sectionName: "general", enabled: on }),
{ wrapper: makeWrapper(), initialProps: { on: false } },
);
expect(mockFetch).not.toHaveBeenCalled();
rerender({ on: true });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("sends only changed fields", async () => {
mockFetch.mockResolvedValue({ appName: "Stirling", theme: "dark" });
const { result } = renderHook(
() =>
useAdminSettings<{ appName: string; theme: string }>({
sectionName: "general",
}),
{ wrapper: makeWrapper() },
);
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => {
result.current.setSettings({ appName: "Renamed", theme: "dark" });
});
await act(async () => {
await result.current.saveSettings();
});
expect(mockPutSection).toHaveBeenCalledWith("general", {
appName: "Renamed",
});
});
it("skips the request when nothing changed", async () => {
const { result } = renderHook(
() => useAdminSettings({ sectionName: "general" }),
{ wrapper: makeWrapper() },
);
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.saveSettings();
});
expect(mockPutSection).not.toHaveBeenCalled();
});
it("refetches after a save so the _pending block is current", async () => {
mockFetch.mockResolvedValue({ appName: "Stirling" });
const { result } = renderHook(
() => useAdminSettings<{ appName: string }>({ sectionName: "general" }),
{
wrapper: makeWrapper(),
},
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(mockFetch).toHaveBeenCalledTimes(1);
mockFetch.mockResolvedValue({
appName: "Stirling",
_pending: { appName: "Renamed" },
});
act(() => {
result.current.setSettings({ appName: "Renamed" });
});
await act(async () => {
await result.current.saveSettings();
});
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2));
await waitFor(() => expect(result.current.hasPendingChanges()).toBe(true));
});
it("surfaces pending values in the draft and flags the field", async () => {
mockFetch.mockResolvedValue({
appName: "Stirling",
_pending: { appName: "Queued" },
});
const { result } = renderHook(
() => useAdminSettings<{ appName: string }>({ sectionName: "general" }),
{
wrapper: makeWrapper(),
},
);
await waitFor(() => expect(result.current.loading).toBe(false));
// The draft shows the queued value, not the active one.
expect(result.current.settings.appName).toBe("Queued");
expect(result.current.isFieldPending("appName")).toBe(true);
});
it("resets the draft when a fetch delivers new values", async () => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const { result } = renderHook(
() => useAdminSettings<{ appName: string }>({ sectionName: "general" }),
{
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
),
},
);
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => {
result.current.setSettings({ appName: "Half-typed" });
});
expect(result.current.settings.appName).toBe("Half-typed");
mockFetch.mockResolvedValue({ appName: "From server" });
await act(async () => {
await client.invalidateQueries({ queryKey: qk.adminSection("general") });
});
// A fetch is authoritative over the draft.
await waitFor(() =>
expect(result.current.settings.appName).toBe("From server"),
);
});
it("does not clobber an in-progress edit on re-render", async () => {
const { result, rerender } = renderHook(
() => useAdminSettings<{ appName: string }>({ sectionName: "general" }),
{
wrapper: makeWrapper(),
},
);
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => {
result.current.setSettings({ appName: "Half-typed" });
});
rerender();
rerender();
expect(result.current.settings.appName).toBe("Half-typed");
});
it("routes transformer output to both endpoints", async () => {
mockFetch.mockResolvedValue({ a: 1, b: 2 });
const { result } = renderHook(
() =>
useAdminSettings<{ a: number; b: number }>({
sectionName: "general",
saveTransformer: (s) => ({
sectionData: { a: s.a },
deltaSettings: { "some.flat.path": s.b },
}),
}),
{ wrapper: makeWrapper() },
);
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => {
result.current.setSettings({ a: 9, b: 8 });
});
await act(async () => {
await result.current.saveSettings();
});
expect(mockPutSection).toHaveBeenCalledWith("general", { a: 9 });
expect(mockPutSettings).toHaveBeenCalledWith({ "some.flat.path": 8 });
});
it("reports saving while the save is in flight", async () => {
const { result } = renderHook(
() => useAdminSettings<{ appName: string }>({ sectionName: "general" }),
{
wrapper: makeWrapper(),
},
);
await waitFor(() => expect(result.current.loading).toBe(false));
let release: () => void = () => {};
mockPutSection.mockReturnValueOnce(
new Promise<void>((resolve) => {
release = resolve;
}),
);
act(() => {
result.current.setSettings({ appName: "Renamed" });
});
let done: Promise<void>;
act(() => {
done = result.current.saveSettings();
});
await waitFor(() => expect(result.current.saving).toBe(true));
await act(async () => {
release();
await done;
});
await waitFor(() => expect(result.current.saving).toBe(false));
});
});
+105 -179
View File
@@ -1,13 +1,24 @@
import { useState, useCallback } from "react";
import apiClient from "@app/services/apiClient";
import { useCallback, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
fetchAdminSection,
putAdminSection,
putAdminSettings,
} from "@app/api/adminSettings";
import { qk } from "@app/query/keys";
import {
mergePendingSettings,
isFieldPending,
hasPendingChanges,
type SettingsWithPending,
} from "@app/utils/settingsPendingHelper";
/** A settings block, which is an object of unknown-shaped fields. */
type SettingsBlock = Record<string, unknown>;
interface UseAdminSettingsOptions<T> {
sectionName: string;
enabled?: boolean;
/**
* Optional transformer to combine data from multiple endpoints.
* If not provided, uses the section response directly.
@@ -18,201 +29,121 @@ interface UseAdminSettingsOptions<T> {
* Returns an object with sectionData and optionally deltaSettings.
*/
saveTransformer?: (settings: T) => {
sectionData: any;
deltaSettings?: Record<string, any>;
sectionData: SettingsBlock;
deltaSettings?: SettingsBlock;
};
}
interface UseAdminSettingsReturn<T> {
settings: T;
rawSettings: any;
rawSettings: (T & SettingsWithPending<T>) | null;
loading: boolean;
saving: boolean;
setSettings: (settings: T) => void;
fetchSettings: () => Promise<void>;
saveSettings: () => Promise<void>;
isFieldPending: (fieldPath: string) => boolean;
hasPendingChanges: () => boolean;
}
/**
* Hook for managing admin settings with automatic pending changes support.
* Includes delta detection to only send changed fields.
*
* @example
* const { settings, setSettings, saveSettings, isFieldPending } = useAdminSettings({
* sectionName: 'legal'
* });
* One config section: the server value, an editable draft over it, and a save
* that sends only what changed. Sections sharing a sectionName share the fetch.
*/
export function useAdminSettings<T = any>(
export function useAdminSettings<T>(
options: UseAdminSettingsOptions<T>,
): UseAdminSettingsReturn<T> {
const { sectionName, fetchTransformer, saveTransformer } = options;
const {
sectionName,
enabled = true,
fetchTransformer,
saveTransformer,
} = options;
const [settings, setSettings] = useState<T>({} as T);
const [rawSettings, setRawSettings] = useState<any>(null);
const [originalSettings, setOriginalSettings] = useState<T>({} as T); // Track original active values
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const queryClient = useQueryClient();
const queryKey = qk.adminSection(sectionName);
const fetchSettings = useCallback(async () => {
try {
setLoading(true);
// Inline closures at the call sites, so their identity changes every render.
const fetchTransformerRef = useRef(fetchTransformer);
fetchTransformerRef.current = fetchTransformer;
const saveTransformerRef = useRef(saveTransformer);
saveTransformerRef.current = saveTransformer;
let rawData: any;
const {
data: rawSettings,
isPending,
isFetching,
} = useQuery({
queryKey,
queryFn: (): Promise<T & SettingsWithPending<T>> =>
fetchTransformerRef.current
? (fetchTransformerRef.current() as Promise<T & SettingsWithPending<T>>)
: fetchAdminSection<T & SettingsWithPending<T>>(sectionName),
enabled,
// Inherits the client's 30s window. Not CONFIG_STALE_TIME: these are
// editable, and a save invalidates. Override it for live server state.
});
if (fetchTransformer) {
// Use custom fetch logic for complex sections
rawData = await fetchTransformer();
} else {
// Simple single-endpoint fetch
const response = await apiClient.get(
`/api/v1/admin/settings/section/${sectionName}`,
);
rawData = response.data || {};
}
// Pending changes folded in: what the form shows, and the delta baseline.
const baseline = useMemo(
() => (rawSettings ? (mergePendingSettings(rawSettings) as T) : ({} as T)),
[rawSettings],
);
console.log(
`[useAdminSettings:${sectionName}] Raw response:`,
JSON.stringify(rawData, null, 2),
);
// Adjusted during render, not in an effect: React re-runs the component
// before committing, so reseeding costs no extra render.
const [draft, setDraft] = useState<T>(baseline);
const seededFrom = useRef(rawSettings);
if (rawSettings !== undefined && seededFrom.current !== rawSettings) {
seededFrom.current = rawSettings;
setDraft(baseline);
}
// Store raw settings (includes _pending if present)
setRawSettings(rawData);
const save = useMutation({
mutationFn: async () => {
const delta = computeDelta(baseline, draft);
if (Object.keys(delta).length === 0) return;
// Merge pending changes into settings for display
const mergedSettings = mergePendingSettings(rawData);
console.log(
`[useAdminSettings:${sectionName}] Merged settings:`,
JSON.stringify(mergedSettings, null, 2),
);
// Store merged settings as original for delta comparison
// This ensures we compare against what the user SAW (with pending), not raw active values
setOriginalSettings(mergedSettings as T);
console.log(
`[useAdminSettings:${sectionName}] Original settings (for comparison):`,
JSON.stringify(mergedSettings, null, 2),
);
setSettings(mergedSettings as T);
} catch (error) {
console.error(
`[useAdminSettings:${sectionName}] Failed to fetch:`,
error,
);
throw error;
} finally {
setLoading(false);
}
}, [sectionName]);
const saveSettings = async () => {
try {
setSaving(true);
// Compute delta: only include fields that changed from original
const delta = computeDelta(originalSettings, settings);
console.log(
`[useAdminSettings:${sectionName}] Delta (changed fields):`,
JSON.stringify(delta, null, 2),
);
if (Object.keys(delta).length === 0) {
console.log(
`[useAdminSettings:${sectionName}] No changes detected, skipping save`,
);
const transform = saveTransformerRef.current;
if (!transform) {
await putAdminSection(sectionName, delta);
return;
}
if (saveTransformer) {
// Use custom save logic for complex sections
const { sectionData, deltaSettings } = saveTransformer(settings);
const { sectionData, deltaSettings } = transform(draft);
const { sectionData: originalSectionData, deltaSettings: originalDelta } =
transform(baseline);
// Get original sectionData using same transformer for fair comparison
const { sectionData: originalSectionData } =
saveTransformer(originalSettings);
// Save section data (with delta applied) - compare transformed vs transformed
const sectionDelta = computeDelta(originalSectionData, sectionData);
if (Object.keys(sectionDelta).length > 0) {
await apiClient.put(
`/api/v1/admin/settings/section/${sectionName}`,
sectionDelta,
);
}
// Save delta settings if provided (filter to only changed values)
if (deltaSettings && Object.keys(deltaSettings).length > 0) {
// Build deltaSettings from original using same transformer to get correct structure
const { deltaSettings: originalDeltaSettings } =
saveTransformer(originalSettings);
console.log(
`[useAdminSettings:${sectionName}] Comparing deltaSettings:`,
{
original: originalDeltaSettings,
current: deltaSettings,
},
);
// Compare current vs original deltaSettings (both have same backend paths)
const changedDeltaSettings: Record<string, any> = {};
for (const [key, value] of Object.entries(deltaSettings)) {
const originalValue = originalDeltaSettings?.[key];
// Only include if value actually changed
if (JSON.stringify(value) !== JSON.stringify(originalValue)) {
changedDeltaSettings[key] = value;
console.log(
`[useAdminSettings:${sectionName}] Delta field changed: ${key}`,
{
original: originalValue,
new: value,
},
);
}
}
if (Object.keys(changedDeltaSettings).length > 0) {
console.log(
`[useAdminSettings:${sectionName}] Sending delta settings:`,
changedDeltaSettings,
);
await apiClient.put("/api/v1/admin/settings", {
settings: changedDeltaSettings,
});
} else {
console.log(
`[useAdminSettings:${sectionName}] No delta settings changed, skipping`,
);
}
}
} else {
// Simple single-endpoint save with delta
await apiClient.put(
`/api/v1/admin/settings/section/${sectionName}`,
delta,
);
const sectionDelta = computeDelta(originalSectionData, sectionData);
if (Object.keys(sectionDelta).length > 0) {
await putAdminSection(sectionName, sectionDelta);
}
// Refetch to get updated _pending block
await fetchSettings();
} catch (error) {
console.error(`[useAdminSettings:${sectionName}] Failed to save:`, error);
throw error;
} finally {
setSaving(false);
}
};
if (deltaSettings && Object.keys(deltaSettings).length > 0) {
const changed: SettingsBlock = {};
for (const [key, value] of Object.entries(deltaSettings)) {
if (JSON.stringify(value) !== JSON.stringify(originalDelta?.[key])) {
changed[key] = value;
}
}
if (Object.keys(changed).length > 0) await putAdminSettings(changed);
}
},
// Refetch rather than trust the draft: the response carries the _pending
// block the badges render from.
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
});
const saveSettings = useCallback(async () => {
await save.mutateAsync();
}, [save]);
return {
settings,
rawSettings,
loading,
saving,
setSettings,
fetchSettings,
settings: draft,
rawSettings: rawSettings ?? null,
// True while disabled too: nothing has loaded.
loading: isPending || isFetching,
saving: save.isPending,
setSettings: setDraft,
saveSettings,
isFieldPending: (fieldPath: string) =>
isFieldPending(rawSettings, fieldPath),
@@ -224,30 +155,25 @@ export function useAdminSettings<T = any>(
* Compute delta between original and current settings.
* Returns only fields that have changed.
*/
function computeDelta(original: any, current: any): any {
const delta: any = {};
function computeDelta(original: unknown, current: unknown): SettingsBlock {
const delta: SettingsBlock = {};
if (!isPlainObject(current)) return delta;
const before: SettingsBlock = isPlainObject(original) ? original : {};
for (const key in current) {
if (!Object.prototype.hasOwnProperty.call(current, key)) continue;
const originalValue = original[key];
for (const key of Object.keys(current)) {
const originalValue = before[key];
const currentValue = current[key];
// Handle nested objects
if (isPlainObject(currentValue) && isPlainObject(originalValue)) {
const nestedDelta = computeDelta(originalValue, currentValue);
if (Object.keys(nestedDelta).length > 0) {
delta[key] = nestedDelta;
}
}
// Handle arrays
else if (Array.isArray(currentValue) && Array.isArray(originalValue)) {
} else if (Array.isArray(currentValue) && Array.isArray(originalValue)) {
if (JSON.stringify(currentValue) !== JSON.stringify(originalValue)) {
delta[key] = currentValue;
}
}
// Handle primitives
else if (currentValue !== originalValue) {
} else if (currentValue !== originalValue) {
delta[key] = currentValue;
}
}
@@ -258,7 +184,7 @@ function computeDelta(original: any, current: any): any {
/**
* Check if value is a plain object (not array, not null, not Date, etc.)
*/
function isPlainObject(value: any): boolean {
function isPlainObject(value: unknown): value is SettingsBlock {
return (
value !== null && typeof value === "object" && value.constructor === Object
);
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,5 +1,7 @@
/** Editor query keys: ["editor", <resource>, ...params]. */
export const qk = {
adminSection: (sectionName: string) =>
["editor", "adminSection", sectionName] as const,
/** The admin directory payload: a different endpoint and shape to qk.users(). */
adminUsers: () => ["editor", "adminUsers"] as const,
appConfig: () => ["editor", "appConfig"] as const,
@@ -0,0 +1,64 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { alert } from "@app/components/toast";
import { handleHttpError } from "@app/services/httpErrorHandler";
// Only the toast surface matters here; the rest of the handler's graph is
// heavy UI that these cases never reach.
vi.mock("@app/components/toast", () => ({ alert: vi.fn() }));
vi.mock("@app/services/specialErrorToasts", () => ({
showSpecialErrorToast: vi.fn().mockReturnValue(false),
}));
vi.mock("@app/services/saasErrorInterceptor", () => ({
handleSaaSError: vi.fn().mockReturnValue(false),
}));
function axiosError(config: Record<string, unknown>, status = 500) {
return {
// The interceptor only ever sees real AxiosErrors; handleHttpError gates on
// axios.isAxiosError, so the fixture must carry the marker.
isAxiosError: true,
config: { url: "/api/v1/general/thing", ...config },
response: { status, data: { error: "boom" } },
message: "Request failed",
};
}
// Pins the half of the `suppressErrorToast` contract that a request-side
// assertion cannot see: that the interceptor reads the flag off the.
describe("handleHttpError - suppressErrorToast", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("suppresses the toast when config.suppressErrorToast is true", async () => {
const suppressed = await handleHttpError(
axiosError({ suppressErrorToast: true }),
);
expect(suppressed).toBe(false);
expect(alert).not.toHaveBeenCalled();
});
it("shows the toast when the flag is absent", async () => {
await handleHttpError(axiosError({}));
expect(alert).toHaveBeenCalled();
});
it("ignores the flag when it is spelled as a request HEADER", async () => {
// The header form is inert - it ships a junk header to the backend and the
// interceptor never looks at it.
await handleHttpError(
axiosError({ headers: { suppressErrorToast: "true" } }),
);
expect(alert).toHaveBeenCalled();
});
it("short-circuits a 401 before the login redirect", async () => {
const before = window.location.href;
const suppressed = await handleHttpError(
axiosError({ suppressErrorToast: true }, 401),
);
expect(suppressed).toBe(false);
expect(alert).not.toHaveBeenCalled();
expect(window.location.href).toBe(before);
});
});
@@ -100,8 +100,11 @@ export async function handleHttpError(error: unknown): Promise<boolean> {
pathname.includes("/auth/") ||
pathname.includes("/invite/");
const isPublicMobilePage =
pathname.includes("/mobile-scanner") || pathname.includes("/mobile-sign");
// If not on auth page, redirect to login with expired session message
if (!isAuthPage && !skipAuthRedirect) {
if (!isAuthPage && !isPublicMobilePage && !skipAuthRedirect) {
if (loginRedirectRecentlyFired()) {
console.warn(
"[httpErrorHandler] 401 redirect already fired moments ago — suppressing repeat to avoid a login loop:",
@@ -290,6 +290,40 @@ function copyToWasmHeap(
(m.pdfium as typeof m.pdfium & ExtendedPdfiumRuntime).HEAPU8.set(bytes, ptr);
}
/** Human-readable message for an FPDF_GetLastError() code. */
function pdfiumOpenErrorMessage(err: number): string {
switch (err) {
case 1:
return "Could not open the PDF (unknown error).";
case 2:
return "This file is not a valid PDF or is corrupted.";
case 3:
return "The PDF file is corrupted and could not be read.";
case 4:
return "This PDF is password-protected.";
case 5:
return "This PDF uses an unsupported security scheme.";
case 6:
return "A page in this PDF could not be loaded.";
default:
return `Could not open the PDF (error ${err}).`;
}
}
/** FPDF_GetLastError() code for a missing/incorrect document password. */
export const FPDF_ERR_PASSWORD = 4;
// Open failure carrying the raw FPDF_GetLastError() code so callers can tell a
// password prompt (code 4) apart from a corrupt file.
export class PdfiumOpenError extends Error {
readonly code: number;
constructor(code: number) {
super(pdfiumOpenErrorMessage(code));
this.name = "PdfiumOpenError";
this.code = code;
}
}
/**
* Load a PDF into PDFium memory and return the document pointer.
* Caller MUST call `closeRawDocument(docPtr)` when finished.
@@ -307,8 +341,7 @@ export async function openRawDocument(
const docPtr = m.FPDF_LoadMemDocument(ptr, len, password ?? "");
if (!docPtr) {
m.pdfium.wasmExports.free(ptr);
const err = m.FPDF_GetLastError();
throw new Error(`PDFium: failed to open document (error ${err})`);
throw new PdfiumOpenError(m.FPDF_GetLastError());
}
// Keep the buffer alive — freed in closeRawDocument()
_docDataPtrs.set(docPtr, ptr);
@@ -0,0 +1,204 @@
import { test, expect } from "@app/tests/helpers/test-base";
import { loginAndSetup } from "@app/tests/helpers/login";
import * as path from "path";
import * as fs from "fs";
// In dev environments where the Stirling backend ships with login disabled
// (anonymous-mode), `loginAndSetup` will throw because /login doesn't render.
async function loginIfNeeded(
page: import("@playwright/test").Page,
): Promise<void> {
try {
await loginAndSetup(page);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (/email|login/i.test(msg)) {
// anonymous-mode backend - nothing to log in to.
return;
}
throw e;
}
}
// Live e2e coverage for the PDF text editor's `backend` charcode strategy.
function fixture(filename: string): string {
const candidates = [
path.resolve(
process.cwd(),
"src",
"core",
"tests",
"test-fixtures",
filename,
),
path.resolve(
process.cwd(),
"frontend",
"src",
"core",
"tests",
"test-fixtures",
filename,
),
];
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
throw new Error(
`Test fixture not found: ${filename} (tried: ${candidates.join(", ")})`,
);
}
// `user-sample.pdf` is the same file as `frontend/editor/public/samples/Sample.pdf`,
// copied into the test fixtures dir so this suite is self-contained.
const USER_SAMPLE_PDF = fixture("user-sample.pdf");
async function gotoEditorWithBackendStrategy(
page: import("@playwright/test").Page,
): Promise<void> {
// `charcodeDebug=1` enables the HUD overlay (CharcodeDebugHud) that
// emits one row per attempt, which is what this test scrapes.
await page.goto("/pdf-text-editor?charcodeStrategy=backend&charcodeDebug=1", {
waitUntil: "domcontentloaded",
});
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
}
async function loadUserSamplePdf(
page: import("@playwright/test").Page,
): Promise<void> {
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(USER_SAMPLE_PDF);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 60_000,
});
}
test.describe("charcode backend strategy (live PDFBox)", () => {
test.describe.configure({ timeout: 120_000 });
test.beforeEach(async ({ page }) => {
await loginIfNeeded(page);
});
test("Sample.pdf 10M+: typing M into the per-glyph Type3 font emits charcodes-ok on the FIRST keystroke", async ({
page,
}) => {
await gotoEditorWithBackendStrategy(page);
await loadUserSamplePdf(page);
// Find the 10M+ run.
const runEl = page
.locator('[data-testid^="pdf-editor-run-p"]')
.filter({ hasText: /^10M\+$/ })
.first();
await expect(runEl).toBeVisible({ timeout: 15_000 });
const runTestId = (await runEl.getAttribute("data-testid")) ?? "";
expect(runTestId).toMatch(/^pdf-editor-run-p\d+-/);
// Listen for the prewarm-complete console.debug log BEFORE we focus the
// run, so we don't race the message.
const prewarmComplete = page.waitForEvent("console", {
predicate: (msg) =>
/\[charcode\] backend prewarm pageIdx=/.test(msg.text()),
timeout: 90_000,
});
// Surface ALL console messages to the test stdout so we can see what's
// happening if the prewarm log doesn't fire.
page.on("console", (msg) => {
if (/charcode|prewarm/.test(msg.text())) {
process.stdout.write(`[page-console-${msg.type()}] ${msg.text()}\n`);
}
});
// Use Playwright's physical click - that dispatches real mousedown/up/click
// + focus events that React's synthetic event system catches reliably.
await runEl.click();
// Wait for prewarm to log "[charcode] backend prewarm pageIdx=
// probes=N".
await prewarmComplete;
// First keystroke: should hit the per-char emit branch on the FIRST try (no
// Helvetica fallback).
await page.evaluate((tid) => {
const el = document.querySelector<HTMLDivElement>(
`[data-testid="${tid}"]`,
);
if (!el) throw new Error(`run ${tid} not in DOM`);
el.focus();
const sel = window.getSelection();
if (!sel) throw new Error("no Selection api");
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
document.execCommand("insertText", false, "M");
}, runTestId);
// The debug HUD was removed from production builds, so verify the emit
// through the window-exposed telemetry buffer instead.
type CharcodeEmitEvent = {
text: string;
outcome: string;
resolved: number[];
};
const readCharcodeEvents = () =>
page.evaluate(
() =>
(
window as unknown as {
__charcode_events?: CharcodeEmitEvent[];
}
).__charcode_events ?? [],
);
await expect
.poll(
async () => {
const events = await readCharcodeEvents();
return events.some(
(e) =>
e.text.includes("M") &&
e.outcome === "charcodes-ok" &&
e.resolved.length > 0,
);
},
{ timeout: 10_000, intervals: [250, 500, 1000] },
)
.toBe(true);
// Cross-check: the editor's model must reflect "10M+M" - the
// typed M became a real text run via the per-char emit branch.
const runText = await page.evaluate((tid) => {
const w = window as unknown as {
__editor_store: {
state: { pages: { runs: { id: string; text: string }[] }[] };
};
};
for (const p of w.__editor_store.state.pages) {
for (const r of p.runs) {
if (`pdf-editor-run-${r.id}` === tid) return r.text;
}
}
return "";
}, runTestId);
expect(runText).toBe("10M+M");
// No-regression guard: the MOST RECENT emit covering "M" must be a
// source-font charcodes-ok emit, NOT a Helvetica fallback.
const events = await readCharcodeEvents();
const mEvents = events.filter((e) => e.text.includes("M"));
const lastM = mEvents[mEvents.length - 1];
expect(
lastM?.outcome,
`latest M emit must be charcodes-ok. Events:\n${JSON.stringify(events, null, 2)}`,
).toBe("charcodes-ok");
});
});
@@ -0,0 +1,147 @@
/** Structural test-only types for the PDF text editor Playwright specs. */
/** Affine matrix on runs/images. */
export interface EditorMatrix {
a: number;
b: number;
c: number;
d: number;
e?: number;
f?: number;
}
/** Axis-aligned bounds; `right` appears on per-line merged bounds. */
export interface EditorBounds {
x: number;
y: number;
width: number;
height: number;
right: number;
}
export interface EditorLineSlot {
mergedFromBounds: EditorBounds[];
}
export interface EditorRun {
id: string;
text: string;
locked: boolean;
fontId: string;
fontSize: number;
fontSubset: boolean;
matrix: EditorMatrix;
bounds: EditorBounds;
pdfiumObjPtr: number;
paragraphLeafPtrs: number[];
mergedFromPtrs: number[];
paragraphLineSlots?: EditorLineSlot[];
/** Inferred letter-spacing (Tc footprint) in PDF points. */
charSpacingPt: number;
/** Glyph outline state; null when the run paints no outline. */
stroke: { r: number; g: number; b: number; a: number } | null;
strokeWidth: number;
/** PDF text render mode (Tr). */
renderMode: number;
/** Captured engine pen origins / ends per code unit of `text`. */
charStartsX: number[] | null;
charEndsX: number[] | null;
charPositionsKey: string | null;
/** Member-line count; > 1 means a multi-line paragraph. */
paragraphLineCount?: number;
}
export interface EditorImage {
id: string;
locked: boolean;
matrix: EditorMatrix;
}
export interface EditorPage {
pageIndex: number;
pagePtr: number;
width: number;
runs: EditorRun[];
images: EditorImage[];
flushGenerate(module: EditorPdfiumModule): void;
}
export interface EditorDoc {
module: EditorPdfiumModule;
page(idx: number): EditorPage;
loadedPages(): EditorPage[];
}
export interface EditorSelectionValue {
runIds: string[];
imageIds: string[];
}
export interface EditorSelection {
selectOne(id: string): void;
selectMany(ids: string[]): void;
selectImage(id: string): void;
clear(): void;
value: EditorSelectionValue;
}
export interface EditorHistorySize {
undo: number;
redo: number;
}
export interface EditorHistory {
size(): EditorHistorySize;
}
export interface EditorEditorStore {
doc: EditorDoc;
selection: EditorSelection;
history: EditorHistory;
resetAll(): void;
}
/** Minimal PDFium WASM surface the specs poke directly. */
export interface EditorPdfiumExports {
malloc(size: number): number;
free(ptr: number): void;
}
export interface EditorPdfiumRuntime {
wasmExports: EditorPdfiumExports;
getValue(ptr: number, type: string): number;
}
export interface EditorPdfiumModule {
pdfium: EditorPdfiumRuntime;
FPDFText_LoadPage(pagePtr: number): number;
FPDFText_ClosePage(textPagePtr: number): void;
FPDFPageObj_GetBounds(
ptr: number,
left: number,
bottom: number,
right: number,
top: number,
): number;
FPDFPageObj_GetMatrix(ptr: number, matrixPtr: number): number;
FPDFTextObj_GetText(
ptr: number,
textPagePtr: number,
buf: number,
len: number,
): number;
}
/** Telemetry buffer entry mirrored onto the window during edits. */
export interface EditorCharcodeEvent {
outcome: string;
strategy?: string;
text?: string;
resolved?: number[];
}
/** The window globals the specs read inside `page.evaluate` closures. */
export interface EditorTestWindow {
__editor_store: EditorEditorStore;
__charcode_events?: EditorCharcodeEvent[];
}
@@ -0,0 +1,126 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import path from "path";
// The canvas renders with FPDF_ANNOT but the editor model walks page objects
// only, so FreeText/widget/stamp text is visible and completely uneditable.
// It must at least be outlined and explained.
const ANNOT_PDF = path.join(
import.meta.dirname,
"../test-fixtures/annotation-text-sample.pdf",
);
test.describe("PDF text editor - annotation-backed text is marked, not silently inert", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 15_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(ANNOT_PDF);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
});
test("widget and FreeText annotations get an outline with an explanation", async ({
page,
}) => {
const outlines = page.locator('[data-testid^="pdf-editor-annot-p0-"]');
await expect(outlines.first()).toBeAttached({ timeout: 15_000 });
const count = await outlines.count();
expect(count, "both annotations should be outlined").toBeGreaterThanOrEqual(
2,
);
const kinds = await outlines.evaluateAll((els) =>
els.map((e) => e.getAttribute("data-annot-kind")),
);
expect(kinds).toContain("widget");
expect(kinds).toContain("freetext");
// Every outline explains itself rather than being a mystery box.
const labels = await outlines.evaluateAll((els) =>
els.map((e) => e.getAttribute("title") ?? ""),
);
for (const label of labels) {
expect(label.length, "outline needs a tooltip").toBeGreaterThan(10);
expect(label).toContain("edited here");
}
});
test("outlines sit over the annotation, not over the editable page text", async ({
page,
}) => {
const outlines = page.locator('[data-testid^="pdf-editor-annot-p0-"]');
await expect(outlines.first()).toBeAttached({ timeout: 15_000 });
const editable = page
.locator('[data-testid^="pdf-editor-run-p0-"]')
.filter({ hasText: "Editable page text" })
.first();
await expect(editable).toBeAttached();
const runBox = await editable.boundingBox();
expect(runBox).not.toBeNull();
const boxes = await outlines.evaluateAll((els) =>
els.map((e) => {
const r = e.getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
}),
);
for (const b of boxes) {
expect(b.w).toBeGreaterThan(1);
expect(b.h).toBeGreaterThan(1);
// No outline may cover the editable run's box.
const overlaps =
b.x < runBox!.x + runBox!.width &&
b.x + b.w > runBox!.x &&
b.y < runBox!.y + runBox!.height &&
b.y + b.h > runBox!.y;
expect(overlaps, "annotation outline must not cover editable text").toBe(
false,
);
}
});
test("the editable page text is still editable with annotations present", async ({
page,
}) => {
const editable = page
.locator('[data-testid^="pdf-editor-run-p0-"]')
.filter({ hasText: "Editable page text" })
.first();
const tid = (await editable.getAttribute("data-testid")) ?? "";
await page.evaluate((id) => {
const el = document.querySelector<HTMLDivElement>(
`[data-testid="${id}"]`,
);
if (!el) throw new Error("run missing");
el.focus();
const sel = window.getSelection();
if (!sel) throw new Error("no selection api");
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
document.execCommand("insertText", false, "!");
}, tid);
await page.waitForTimeout(200);
const text = await page.evaluate((id) => {
const w = window as unknown as {
__editor_store: {
state: { pages: { runs: { id: string; text: string }[] }[] };
};
};
for (const p of w.__editor_store.state.pages) {
for (const r of p.runs)
if (`pdf-editor-run-${r.id}` === id) return r.text;
}
return "";
}, tid);
expect(text).toBe("Editable page text!");
});
});
@@ -0,0 +1,83 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import path from "path";
// Rewriting the editing host inside a `beforeinput` handler makes WebKit
// abandon the pending insertion: `input` never fires and the DOM never
// changes, so every edit is silently dropped. Chromium tolerates it, so this
// only shows up on WebKit - which is every browser on iOS.
test.describe("PDF text editor - beforeinput must not mutate the DOM", () => {
test("an inserted character reaches the DOM and the model", async ({
page,
}) => {
await page.goto("/pdf-text-editor?charcodeStrategy=content-stream", {
waitUntil: "domcontentloaded",
});
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(
path.join(import.meta.dirname, "../test-fixtures/cropbox-rotate90.pdf"),
);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(800);
const id = await page.evaluate(() => {
/* eslint-disable @typescript-eslint/no-explicit-any */
const s = (window as any).__editor_store;
return s.doc.page(0).runs[0]?.id ?? "";
/* eslint-enable @typescript-eslint/no-explicit-any */
});
expect(id).toMatch(/^p0-/);
await page.locator(`[data-testid="pdf-editor-run-${id}"]`).click();
await page.waitForTimeout(150);
const result = await page.evaluate((rid) => {
/* eslint-disable @typescript-eslint/no-explicit-any */
const el = document.querySelector<HTMLDivElement>(
`[data-testid="pdf-editor-run-${rid}"]`,
)!;
const events: string[] = [];
el.addEventListener("beforeinput", () => events.push("beforeinput"));
el.addEventListener("input", () => events.push("input"));
el.focus();
const sel = window.getSelection()!;
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
const before = el.innerText;
document.execCommand("insertText", false, "Z");
return { before, after: el.innerText, events };
/* eslint-enable @typescript-eslint/no-explicit-any */
}, id);
// The insertion must actually land: `input` firing is what carries it to
// the model, and a cancelled beforeinput suppresses exactly that.
expect(result.events).toContain("input");
expect(result.after).not.toBe(result.before);
expect(result.after).toContain("Z");
await page.evaluate(
(rid) =>
document
.querySelector<HTMLElement>(`[data-testid="pdf-editor-run-${rid}"]`)
?.blur(),
id,
);
await page.waitForTimeout(600);
const modelText = await page.evaluate(() => {
/* eslint-disable @typescript-eslint/no-explicit-any */
const s = (window as any).__editor_store;
return s.doc.page(0).runs[0]?.text ?? "";
/* eslint-enable @typescript-eslint/no-explicit-any */
});
expect(modelText).toContain("Z");
});
});
@@ -0,0 +1,129 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import path from "path";
// A caret parked at the overlay CONTAINER's end (rather than inside the last
// painted line block) makes Firefox insert typed text as a bare sibling of the
// line div. innerText then joins the two as separate blocks, so the model gains
// a line break the user never typed - which pushes the run down the
// multi-object re-emit path and re-emits it as a paragraph.
const USER_SAMPLE_PDF = path.join(
import.meta.dirname,
"../test-fixtures/user-sample.pdf",
);
const SAMPLE_PDF = path.join(
import.meta.dirname,
"../test-fixtures/sample.pdf",
);
async function openEditor(page: import("@playwright/test").Page, file: string) {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 15_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(file);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
}
function modelTextOf(page: import("@playwright/test").Page, testId: string) {
return page.evaluate((id) => {
const w = window as unknown as {
__editor_store: {
state: { pages: { runs: { id: string; text: string }[] }[] };
};
};
for (const p of w.__editor_store.state.pages) {
for (const r of p.runs)
if (`pdf-editor-run-${r.id}` === id) return r.text;
}
return "";
}, testId);
}
test.describe("PDF text editor - caret drift must not invent line breaks", () => {
test("typing at a container-level caret appends to the line, not a new one", async ({
page,
}) => {
await openEditor(page, USER_SAMPLE_PDF);
const run = page
.locator('[data-testid^="pdf-editor-run-p0-"]')
.filter({ hasText: /^10M\+$/ })
.first();
if ((await run.count()) === 0) {
test.skip(true, "fixture is missing the 10M+ run");
return;
}
const tid = (await run.getAttribute("data-testid")) ?? "";
// Park the caret at the CONTAINER's end - the position that used to drift.
for (const ch of ["A", "B"]) {
await page.evaluate(
({ tid, ch }) => {
const el = document.querySelector<HTMLDivElement>(
`[data-testid="${tid}"]`,
);
if (!el) throw new Error("run missing");
el.focus();
const sel = window.getSelection();
if (!sel) throw new Error("no selection api");
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
document.execCommand("insertText", false, ch);
},
{ tid, ch },
);
await page.waitForTimeout(120);
}
const text = await modelTextOf(page, tid);
expect(text, "typed chars must land on the same line").toBe("10M+AB");
expect(text).not.toContain("\n");
});
test("keyboard focus puts the caret inside the last line block", async ({
page,
}) => {
await openEditor(page, SAMPLE_PDF);
const run = page.locator('[data-testid^="pdf-editor-run-p0-"]').first();
const tid = (await run.getAttribute("data-testid")) ?? "";
const before = await modelTextOf(page, tid);
// Focus WITHOUT a pointer, which is the path that positions the caret.
await page.evaluate((id) => {
document.querySelector<HTMLDivElement>(`[data-testid="${id}"]`)?.focus();
}, tid);
await page.waitForTimeout(120);
const anchorInsideBlock = await page.evaluate((id) => {
const el = document.querySelector<HTMLDivElement>(
`[data-testid="${id}"]`,
);
const sel = window.getSelection();
if (!el || !sel || sel.rangeCount === 0) return false;
const node = sel.anchorNode;
if (!node) return false;
// The caret must sit in a text node, not on the container itself.
return node !== el && el.contains(node);
}, tid);
expect(
anchorInsideBlock,
"caret should be inside the painted line, not on the container",
).toBe(true);
// Wherever the caret lands, typing must keep the run on ONE line and lose
// nothing: a container-level caret used to split the run in two.
await page.keyboard.insertText("QQ");
await page.waitForTimeout(150);
const after = await modelTextOf(page, tid);
expect(after).not.toContain("\n");
expect(after.replace("QQ", "")).toBe(before);
expect(after).toContain("QQ");
});
});
@@ -0,0 +1,106 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import path from "path";
// Transforming an object without its clip path leaves the clip behind, so
// moved clipped content gets sliced by a stale rectangle. Driven through the
// real Ctrl+drag gesture and the exposed store: CI serves a production build,
// where importing a `/src/...` module by path does not resolve.
test.describe("PDF text editor - clip paths follow their object", () => {
test("a run move transforms the clip path by the same matrix, and undo reverses both", async ({
page,
}) => {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(
path.join(import.meta.dirname, "../test-fixtures/sample.pdf"),
);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(800);
// Record every object transform and every clip transform, in order.
await page.evaluate(() => {
/* eslint-disable @typescript-eslint/no-explicit-any */
const w = window as any;
const m = (w.__editor_store.doc ?? w.__editor_store.document).module;
w.__clipProbe = [] as Array<{ kind: string; args: number[] }>;
const realObj = m.FPDFPageObj_Transform.bind(m);
const realClip = m.FPDFPageObj_TransformClipPath.bind(m);
m.FPDFPageObj_Transform = (...args: number[]) => {
w.__clipProbe.push({ kind: "object", args: args.slice(1) });
return realObj(...args);
};
m.FPDFPageObj_TransformClipPath = (...args: number[]) => {
w.__clipProbe.push({ kind: "clip", args: args.slice(1) });
return realClip(...args);
};
/* eslint-enable @typescript-eslint/no-explicit-any */
});
const run = page.locator('[data-testid^="pdf-editor-run-p0-"]').first();
await expect(run).toBeVisible({ timeout: 30_000 });
const box = await run.boundingBox();
if (!box) throw new Error("text run has no bounding box");
await page.keyboard.down("Control");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(
box.x + box.width / 2 + 60,
box.y + box.height / 2 + 20,
{ steps: 5 },
);
await page.mouse.up();
await page.keyboard.up("Control");
await expect(page.getByTestId("pdf-editor-undo")).toBeEnabled({
timeout: 10_000,
});
const read = () =>
page.evaluate(
() =>
(
window as unknown as {
__clipProbe: Array<{ kind: string; args: number[] }>;
}
).__clipProbe,
);
const afterMove = await read();
const objMoves = afterMove.filter((c) => c.kind === "object");
const clipMoves = afterMove.filter((c) => c.kind === "clip");
expect(objMoves.length).toBeGreaterThan(0);
// One clip transform per object transform, with an identical matrix.
expect(clipMoves.length).toBe(objMoves.length);
expect(clipMoves.map((c) => c.args)).toEqual(objMoves.map((c) => c.args));
// The gesture really translated something.
expect(
Math.abs(objMoves[0].args[4]) + Math.abs(objMoves[0].args[5]),
).toBeGreaterThan(0);
await page.keyboard.press("Control+z");
await expect
.poll(
async () => (await read()).filter((c) => c.kind === "object").length,
{
timeout: 10_000,
},
)
.toBeGreaterThan(objMoves.length);
const afterUndo = await read();
const objAll = afterUndo.filter((c) => c.kind === "object");
const clipAll = afterUndo.filter((c) => c.kind === "clip");
expect(clipAll.length).toBe(objAll.length);
expect(clipAll.map((c) => c.args)).toEqual(objAll.map((c) => c.args));
// Undo puts the object back, so its translation is the negation.
const undone = objAll[objAll.length - 1].args;
expect(undone[4]).toBeCloseTo(-objMoves[0].args[4], 5);
expect(undone[5]).toBeCloseTo(-objMoves[0].args[5], 5);
});
});
@@ -0,0 +1,881 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import path from "path";
import type {
EditorMatrix,
EditorTestWindow,
} from "@app/tests/stubbed/editorTestTypes";
// Combined-feature regression suite: the new editor features AND their
// interaction with the 12 bug fixes.
const SAMPLE = path.join(
import.meta.dirname,
"../../../../public/samples/Sample.pdf",
);
const PNG = path.join(import.meta.dirname, "../test-fixtures/sample.png");
// z-order / align / distribute live in the toolbar's "Arrange" menu, and
// image rotate/flip in the "Image" menu. Open the menu, then click the item.
async function clickArrange(page: Page, testid: string): Promise<void> {
await page.getByTestId("pdf-editor-arrange-menu").click();
await page.getByTestId(testid).click();
}
async function clickImage(page: Page, testid: string): Promise<void> {
await page.getByTestId("pdf-editor-imgop-menu").click();
await page.getByTestId(testid).click();
}
async function open(page: Page, firstPage = 0): Promise<void> {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 15_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(SAMPLE);
await expect(page.getByTestId(`pdf-editor-page-${firstPage}`)).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(900);
}
async function runId(
page: Page,
pageIdx: number,
src: string,
): Promise<string> {
const id = await page.evaluate(
({ pageIdx, src }: { pageIdx: number; src: string }) => {
const s = (window as unknown as EditorTestWindow).__editor_store;
const r = s.doc
.page(pageIdx)
.runs.find((x) => new RegExp(src).test(x.text));
return r ? r.id : null;
},
{ pageIdx, src },
);
if (!id) throw new Error(`run /${src}/ not found`);
return id;
}
async function selectRun(page: Page, id: string): Promise<void> {
await page.evaluate(
(rid: string) =>
(
window as unknown as EditorTestWindow
).__editor_store.selection.selectOne(rid),
id,
);
await page.waitForTimeout(120);
}
async function selectMany(page: Page, ids: string[]): Promise<void> {
await page.evaluate(
(rids: string[]) =>
(
window as unknown as EditorTestWindow
).__editor_store.selection.selectMany(rids),
ids,
);
await page.waitForTimeout(120);
}
async function runText(
page: Page,
pageIdx: number,
id: string,
): Promise<string> {
return page.evaluate(
({ pageIdx, id }: { pageIdx: number; id: string }) => {
const r = (window as unknown as EditorTestWindow).__editor_store.doc
.page(pageIdx)
.runs.find((x) => x.id === id);
return r ? (r.text as string) : "(gone)";
},
{ pageIdx, id },
);
}
async function insertImage(
page: Page,
): Promise<{ id: string; matrix: EditorMatrix } | null> {
await page
.locator('[data-testid="pdf-editor-image-input"]')
.setInputFiles(PNG);
await page.waitForTimeout(1200);
return page.evaluate(() => {
const s = (window as unknown as EditorTestWindow).__editor_store;
for (const p of s.doc.loadedPages()) {
if (p.images.length > 0) {
const img = p.images[p.images.length - 1];
return { id: img.id, matrix: { ...img.matrix } };
}
}
return null;
});
}
async function imageMatrix(
page: Page,
imageId: string,
): Promise<EditorMatrix | null> {
return page.evaluate((iid: string) => {
const s = (window as unknown as EditorTestWindow).__editor_store;
for (const p of s.doc.loadedPages()) {
const img = p.images.find((x) => x.id === iid);
if (img) return { ...img.matrix };
}
return null;
}, imageId);
}
async function totalRuns(page: Page): Promise<number> {
return page.evaluate(() =>
(window as unknown as EditorTestWindow).__editor_store.doc
.loadedPages()
.reduce((n: number, p) => n + p.runs.length, 0),
);
}
/** Text of the single selected run - paste selects the run it inserts. */
async function selectedRunText(page: Page): Promise<string> {
return page.evaluate(() => {
const s = (window as unknown as EditorTestWindow).__editor_store;
const ids = s.selection.value.runIds;
if (ids.length !== 1) return `(selection holds ${ids.length} runs)`;
for (const p of s.doc.loadedPages())
for (const r of p.runs) if (r.id === ids[0]) return r.text;
return "(gone)";
});
}
async function countRunsContaining(page: Page, sub: string): Promise<number> {
return page.evaluate((sub: string) => {
const s = (window as unknown as EditorTestWindow).__editor_store;
const needle = sub.toLowerCase();
let n = 0;
for (const p of s.doc.loadedPages())
for (const r of p.runs) if (r.text.toLowerCase().includes(needle)) n += 1;
return n;
}, sub);
}
async function firstRunIds(
page: Page,
pageIdx: number,
n: number,
): Promise<string[]> {
return page.evaluate(
({ pageIdx, n }: { pageIdx: number; n: number }) =>
(window as unknown as EditorTestWindow).__editor_store.doc
.page(pageIdx)
.runs.slice(0, n)
.map((r) => r.id),
{ pageIdx, n },
);
}
async function undoSize(page: Page): Promise<number> {
return page.evaluate(
() =>
(window as unknown as EditorTestWindow).__editor_store.history.size()
.undo,
);
}
test.describe("PDF text editor - combined feature set", () => {
// Editor edits fire encode-charcodes; with no backend an UNMOCKED call 401s
// and redirects to login, unmounting the editor.
test.beforeEach(async ({ page }) => {
await page.route("**/encode-charcodes", (route) => route.abort());
});
test("image insert (real png) adds an image, then rotate-cw changes its matrix and undo reverts", async ({
page,
}) => {
await open(page, 0);
const ins = await insertImage(page);
expect(ins, "image insert must add an image").not.toBeNull();
await page.evaluate(
(iid: string) =>
(
window as unknown as EditorTestWindow
).__editor_store.selection.selectImage(iid),
ins!.id,
);
await page.waitForTimeout(120);
await clickImage(page, "pdf-editor-imgop-rotate-cw");
await page.waitForTimeout(300);
const rotated = await imageMatrix(page, ins!.id);
// A 90deg rotation swaps the axes: original diagonal (a,d) becomes off-diagonal (b,c).
expect(
Math.abs(rotated!.a) + Math.abs(rotated!.d),
"rotate must move scale off the main diagonal",
).toBeLessThan(Math.abs(rotated!.b) + Math.abs(rotated!.c) + 0.01);
await page.getByTestId("pdf-editor-undo").click();
await page.waitForTimeout(300);
const reverted = await imageMatrix(page, ins!.id);
expect(reverted!.a).toBeCloseTo(ins!.matrix.a, 1);
expect(reverted!.d).toBeCloseTo(ins!.matrix.d, 1);
});
test("image flip-h mirrors the matrix and undo reverts", async ({ page }) => {
await open(page, 0);
const ins = await insertImage(page);
expect(ins).not.toBeNull();
await page.evaluate(
(iid: string) =>
(
window as unknown as EditorTestWindow
).__editor_store.selection.selectImage(iid),
ins!.id,
);
await page.waitForTimeout(120);
await clickImage(page, "pdf-editor-imgop-flip-h");
await page.waitForTimeout(300);
const flipped = await imageMatrix(page, ins!.id);
expect(Math.sign(flipped!.a), "flip-h negates horizontal scale").toBe(
-Math.sign(ins!.matrix.a || 1),
);
await page.getByTestId("pdf-editor-undo").click();
await page.waitForTimeout(300);
const reverted = await imageMatrix(page, ins!.id);
expect(reverted!.a).toBeCloseTo(ins!.matrix.a, 1);
});
test("change case UPPER then LOWER transforms the selected run's text", async ({
page,
}) => {
await open(page, 1);
const id = await runId(page, 1, "Comprehensive\\s+toolkit");
const orig = await runText(page, 1, id);
await selectRun(page, id);
await page.getByTestId("pdf-editor-change-case").click();
await page.getByTestId("pdf-editor-change-case-upper").click();
await page.waitForTimeout(400);
const upper = await runText(page, 1, id);
expect(upper).toBe(orig.toUpperCase());
await selectRun(page, id);
await page.getByTestId("pdf-editor-change-case").click();
await page.getByTestId("pdf-editor-change-case-lower").click();
await page.waitForTimeout(400);
const lower = await runText(page, 1, id);
expect(lower).toBe(orig.toLowerCase());
});
test("lock makes a run inert (no select on click); unlock restores it", async ({
page,
}) => {
await open(page, 1);
const id = await runId(page, 1, "Stirling\\s+PDF\\s+is\\s+a\\s+robust");
await selectRun(page, id);
await page.getByTestId("pdf-editor-toggle-lock").click();
await page.waitForTimeout(200);
const locked = await page.evaluate(
(rid: string) =>
(window as unknown as EditorTestWindow).__editor_store.doc
.page(1)
.runs.find((x) => x.id === rid)!.locked,
id,
);
expect(locked, "run should be locked").toBe(true);
// The overlay snapshot must refresh so the lock takes visible effect:
// a locked run drops contentEditable and exposes data-locked.
await expect(page.getByTestId(`pdf-editor-run-${id}`)).toHaveAttribute(
"data-locked",
"true",
);
await expect(page.getByTestId(`pdf-editor-run-${id}`)).toHaveAttribute(
"contenteditable",
"false",
);
// Clear selection, then clicking the locked run must NOT select it.
await page.evaluate(() =>
(window as unknown as EditorTestWindow).__editor_store.selection.clear(),
);
await page
.getByTestId(`pdf-editor-run-${id}`)
.click()
.catch(() => {});
await page.waitForTimeout(150);
const selAfterClick = await page.evaluate(
() =>
(window as unknown as EditorTestWindow).__editor_store.selection.value
.runIds.length,
);
expect(selAfterClick, "locked run must not be selectable by click").toBe(0);
});
test("align-left makes selected runs share the same left x", async ({
page,
}) => {
await open(page, 1);
const a = await runId(page, 1, "Stirling\\s+PDF\\s+is\\s+a\\s+robust");
const b = await runId(page, 1, "Comprehensive\\s+toolkit");
await selectMany(page, [a, b]);
await clickArrange(page, "pdf-editor-align-left");
await page.waitForTimeout(300);
const xs = await page.evaluate(
({ a, b }: { a: string; b: string }) => {
const pg = (
window as unknown as EditorTestWindow
).__editor_store.doc.page(1);
const ra = pg.runs.find((x) => x.id === a)!;
const rb = pg.runs.find((x) => x.id === b)!;
return [ra.bounds.x, rb.bounds.x];
},
{ a, b },
);
expect(
Math.abs(xs[0] - xs[1]),
"aligned runs share a left edge",
).toBeLessThan(1.5);
});
test("cut (Ctrl+X) removes the run and paste (Ctrl+V) brings it back", async ({
page,
}) => {
await open(page, 1);
const id = await runId(page, 1, "Comprehensive\\s+toolkit");
const before = await totalRuns(page);
const cutText = await runText(page, 1, id);
await selectRun(page, id);
// Cut is suppressed while focus is inside a contentEditable run (so the
// browser's native cut wins there).
await page.evaluate(() =>
(document.activeElement as HTMLElement | null)?.blur(),
);
// Ctrl+X / Ctrl+V ride the native cut/paste ClipboardEvent, which needs no
// permission grant and behaves identically on every engine.
await page.keyboard.press("Control+x");
await expect
.poll(() => totalRuns(page), { message: "cut removes the run" })
.toBeLessThan(before);
const afterCut = await totalRuns(page);
await page.keyboard.press("Control+v");
await expect
.poll(() => totalRuns(page), { message: "paste re-adds a run" })
.toBeGreaterThan(afterCut);
expect(await selectedRunText(page), "paste restores the cut text").toBe(
cutText,
);
});
test("z-order: bring-to-front on an inserted image applies and undoes cleanly", async ({
page,
}) => {
await open(page, 0);
const ins = await insertImage(page);
expect(ins).not.toBeNull();
await page.evaluate(
(iid: string) =>
(
window as unknown as EditorTestWindow
).__editor_store.selection.selectImage(iid),
ins!.id,
);
await page.waitForTimeout(120);
const undoBefore = await page.evaluate(
() =>
(window as unknown as EditorTestWindow).__editor_store.history.size()
.undo,
);
await clickArrange(page, "pdf-editor-z-to-front");
await page.waitForTimeout(300);
const undoAfter = await page.evaluate(
() =>
(window as unknown as EditorTestWindow).__editor_store.history.size()
.undo,
);
expect(undoAfter, "z-order is its own undo step").toBe(undoBefore + 1);
// No crash + still one image present.
const imgs = await page.evaluate(() =>
(window as unknown as EditorTestWindow).__editor_store.doc
.loadedPages()
.reduce((n: number, p) => n + p.images.length, 0),
);
expect(imgs).toBeGreaterThan(0);
});
test("editing a run still preserves unedited paragraph lines (fix holds in combined build)", async ({
page,
}) => {
await open(page, 1);
const id = await runId(page, 1, "Stirling\\s+PDF\\s+is\\s+a\\s+robust");
const before = await page.evaluate(
(rid: string) => [
...(window as unknown as EditorTestWindow).__editor_store.doc
.page(1)
.runs.find((x) => x.id === rid)!.paragraphLeafPtrs,
],
id,
);
await page.evaluate((rid: string) => {
const el = document.querySelector<HTMLDivElement>(
`[data-testid="pdf-editor-run-${rid}"]`,
)!;
el.focus();
const sel = window.getSelection()!;
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
document.execCommand("insertText", false, " APPENDED");
}, id);
await page.waitForTimeout(150);
await page.evaluate(
(rid: string) =>
document
.querySelector<HTMLElement>(`[data-testid="pdf-editor-run-${rid}"]`)
?.blur(),
id,
);
await page.waitForTimeout(1200);
const after = await page.evaluate((rid: string) => {
const r = (window as unknown as EditorTestWindow).__editor_store.doc
.page(1)
.runs.find((x) => x.id === rid);
return r ? [...r.paragraphLeafPtrs] : [];
}, id);
const kept = before.filter((p: number) => after.includes(p)).length;
expect(
kept,
"most original glyph objects survive an append",
).toBeGreaterThan(before.length * 0.6);
const text = await runText(page, 1, id);
expect(text).toContain("APPENDED");
expect(text).not.toContain("ÿ");
});
test("image rotate-ccw changes the matrix and undo reverts", async ({
page,
}) => {
await open(page, 0);
const ins = await insertImage(page);
expect(ins).not.toBeNull();
await page.evaluate(
(iid: string) =>
(
window as unknown as EditorTestWindow
).__editor_store.selection.selectImage(iid),
ins!.id,
);
await page.waitForTimeout(120);
await clickImage(page, "pdf-editor-imgop-rotate-ccw");
await page.waitForTimeout(300);
const rotated = await imageMatrix(page, ins!.id);
expect(
Math.abs(rotated!.a) + Math.abs(rotated!.d),
"rotate moves scale off the main diagonal",
).toBeLessThan(Math.abs(rotated!.b) + Math.abs(rotated!.c) + 0.01);
await page.getByTestId("pdf-editor-undo").click();
await page.waitForTimeout(300);
const reverted = await imageMatrix(page, ins!.id);
expect(reverted!.a).toBeCloseTo(ins!.matrix.a, 1);
expect(reverted!.d).toBeCloseTo(ins!.matrix.d, 1);
});
test("image flip-v mirrors the vertical scale and undo reverts", async ({
page,
}) => {
await open(page, 0);
const ins = await insertImage(page);
expect(ins).not.toBeNull();
await page.evaluate(
(iid: string) =>
(
window as unknown as EditorTestWindow
).__editor_store.selection.selectImage(iid),
ins!.id,
);
await page.waitForTimeout(120);
await clickImage(page, "pdf-editor-imgop-flip-v");
await page.waitForTimeout(300);
const flipped = await imageMatrix(page, ins!.id);
expect(Math.sign(flipped!.d), "flip-v negates vertical scale").toBe(
-Math.sign(ins!.matrix.d || 1),
);
await page.getByTestId("pdf-editor-undo").click();
await page.waitForTimeout(300);
const reverted = await imageMatrix(page, ins!.id);
expect(reverted!.d).toBeCloseTo(ins!.matrix.d, 1);
});
test("rotating an image four times clockwise returns to the original matrix", async ({
page,
}) => {
await open(page, 0);
const ins = await insertImage(page);
expect(ins).not.toBeNull();
await page.evaluate(
(iid: string) =>
(
window as unknown as EditorTestWindow
).__editor_store.selection.selectImage(iid),
ins!.id,
);
await page.waitForTimeout(120);
for (let i = 0; i < 4; i++) {
await clickImage(page, "pdf-editor-imgop-rotate-cw");
await page.waitForTimeout(180);
}
const m = await imageMatrix(page, ins!.id);
expect(m!.a).toBeCloseTo(ins!.matrix.a, 1);
expect(m!.d).toBeCloseTo(ins!.matrix.d, 1);
expect(Math.abs(m!.b), "no residual shear after full turn").toBeLessThan(
0.01,
);
expect(Math.abs(m!.c), "no residual shear after full turn").toBeLessThan(
0.01,
);
});
test("locking an image makes it inert; unlocking restores selectability", async ({
page,
}) => {
await open(page, 0);
const ins = await insertImage(page);
expect(ins).not.toBeNull();
await page.evaluate(
(iid: string) =>
(
window as unknown as EditorTestWindow
).__editor_store.selection.selectImage(iid),
ins!.id,
);
await page.waitForTimeout(120);
await page.getByTestId("pdf-editor-toggle-lock").click();
await page.waitForTimeout(200);
const locked = await page.evaluate((iid: string) => {
const s = (window as unknown as EditorTestWindow).__editor_store;
for (const p of s.doc.loadedPages()) {
const im = p.images.find((x) => x.id === iid);
if (im) return im.locked;
}
return null;
}, ins!.id);
expect(locked, "image should be locked").toBe(true);
// Snapshot must refresh so the handle reflects the lock.
await expect(
page.getByTestId(`pdf-editor-image-${ins!.id}`),
).toHaveAttribute("data-locked", "true");
// Clicking the locked image must not select it.
await page.evaluate(() =>
(window as unknown as EditorTestWindow).__editor_store.selection.clear(),
);
await page
.getByTestId(`pdf-editor-image-${ins!.id}`)
.click()
.catch(() => {});
await page.waitForTimeout(150);
const selImgs = await page.evaluate(
() =>
(window as unknown as EditorTestWindow).__editor_store.selection.value
.imageIds,
);
expect(
selImgs.includes(ins!.id),
"locked image not selectable by click",
).toBe(false);
// Unlock via store-selection (bypasses the inert UI) then toggle.
await page.evaluate(
(iid: string) =>
(
window as unknown as EditorTestWindow
).__editor_store.selection.selectImage(iid),
ins!.id,
);
await page.getByTestId("pdf-editor-toggle-lock").click();
await page.waitForTimeout(200);
await expect(
page.getByTestId(`pdf-editor-image-${ins!.id}`),
).not.toHaveAttribute("data-locked", "true");
});
test("z-order: send-to-back is its own undo step and keeps the image", async ({
page,
}) => {
await open(page, 0);
const ins = await insertImage(page);
expect(ins).not.toBeNull();
await page.evaluate(
(iid: string) =>
(
window as unknown as EditorTestWindow
).__editor_store.selection.selectImage(iid),
ins!.id,
);
await page.waitForTimeout(120);
const undoBefore = await undoSize(page);
await clickArrange(page, "pdf-editor-z-to-back");
await page.waitForTimeout(300);
expect(await undoSize(page), "send-to-back is one undo step").toBe(
undoBefore + 1,
);
const imgs = await page.evaluate(() =>
(window as unknown as EditorTestWindow).__editor_store.doc
.loadedPages()
.reduce((n: number, p) => n + p.images.length, 0),
);
expect(imgs).toBeGreaterThan(0);
});
test("z-order: forward then backward each add an undoable step", async ({
page,
}) => {
await open(page, 0);
const ins = await insertImage(page);
expect(ins).not.toBeNull();
await page.evaluate(
(iid: string) =>
(
window as unknown as EditorTestWindow
).__editor_store.selection.selectImage(iid),
ins!.id,
);
await page.waitForTimeout(120);
const base = await undoSize(page);
await clickArrange(page, "pdf-editor-z-forward");
await page.waitForTimeout(250);
await clickArrange(page, "pdf-editor-z-backward");
await page.waitForTimeout(250);
expect(await undoSize(page), "two z-order steps recorded").toBe(base + 2);
await page.getByTestId("pdf-editor-undo").click();
await page.waitForTimeout(200);
expect(await undoSize(page)).toBe(base + 1);
});
test("align-right makes selected runs share the same right edge", async ({
page,
}) => {
await open(page, 1);
const a = await runId(page, 1, "Stirling\\s+PDF\\s+is\\s+a\\s+robust");
const b = await runId(page, 1, "Comprehensive\\s+toolkit");
await selectMany(page, [a, b]);
await clickArrange(page, "pdf-editor-align-right");
await page.waitForTimeout(300);
const rights = await page.evaluate(
({ a, b }: { a: string; b: string }) => {
const pg = (
window as unknown as EditorTestWindow
).__editor_store.doc.page(1);
const ra = pg.runs.find((x) => x.id === a)!;
const rb = pg.runs.find((x) => x.id === b)!;
return [ra.bounds.x + ra.bounds.width, rb.bounds.x + rb.bounds.width];
},
{ a, b },
);
expect(
Math.abs(rights[0] - rights[1]),
"aligned runs share a right edge",
).toBeLessThan(1.5);
});
test("align-top makes selected runs share the same top edge", async ({
page,
}) => {
await open(page, 1);
const a = await runId(page, 1, "Stirling\\s+PDF\\s+is\\s+a\\s+robust");
const b = await runId(page, 1, "Comprehensive\\s+toolkit");
await selectMany(page, [a, b]);
await clickArrange(page, "pdf-editor-align-top");
await page.waitForTimeout(300);
const tops = await page.evaluate(
({ a, b }: { a: string; b: string }) => {
const pg = (
window as unknown as EditorTestWindow
).__editor_store.doc.page(1);
const ra = pg.runs.find((x) => x.id === a)!;
const rb = pg.runs.find((x) => x.id === b)!;
return [ra.bounds.y + ra.bounds.height, rb.bounds.y + rb.bounds.height];
},
{ a, b },
);
expect(
Math.abs(tops[0] - tops[1]),
"aligned runs share a top edge",
).toBeLessThan(1.5);
});
test("distribute-v equalizes the vertical gaps across three runs", async ({
page,
}) => {
// Page text runs are stacked vertically, so vertical distribution is the
// natural axis.
await open(page, 1);
const ids = await firstRunIds(page, 1, 3);
expect(ids.length, "need three runs to distribute").toBe(3);
await selectMany(page, ids);
await clickArrange(page, "pdf-editor-distribute-v");
await page.waitForTimeout(300);
const gaps = await page.evaluate((ids: string[]) => {
const pg = (
window as unknown as EditorTestWindow
).__editor_store.doc.page(1);
const items = ids
.map((id) => pg.runs.find((r) => r.id === id)!)
.map((r) => ({ y: r.bounds.y, h: r.bounds.height }))
.sort((p, q) => p.y - q.y);
const g: number[] = [];
for (let i = 1; i < items.length; i++) {
g.push(items[i].y - (items[i - 1].y + items[i - 1].h));
}
return g;
}, ids);
expect(
Math.abs(gaps[0] - gaps[1]),
"consecutive gaps become equal",
).toBeLessThan(1.0);
});
test("change case Title Case transforms the selected run", async ({
page,
}) => {
await open(page, 1);
const id = await runId(page, 1, "Comprehensive\\s+toolkit");
const orig = await runText(page, 1, id);
const expected = orig.replace(
/\b\w[\w']*/g,
(w) => w[0].toUpperCase() + w.slice(1).toLowerCase(),
);
await selectRun(page, id);
await page.getByTestId("pdf-editor-change-case").click();
await page.getByTestId("pdf-editor-change-case-title").click();
await page.waitForTimeout(400);
expect(await runText(page, 1, id)).toBe(expected);
});
test("change case Sentence case capitalizes after a lowercase pass", async ({
page,
}) => {
await open(page, 1);
const id = await runId(page, 1, "Comprehensive\\s+toolkit");
const orig = await runText(page, 1, id);
await selectRun(page, id);
await page.getByTestId("pdf-editor-change-case").click();
await page.getByTestId("pdf-editor-change-case-lower").click();
await page.waitForTimeout(400);
await selectRun(page, id);
await page.getByTestId("pdf-editor-change-case").click();
await page.getByTestId("pdf-editor-change-case-sentence").click();
await page.waitForTimeout(400);
const expected = orig
.toLowerCase()
.replace(/(^\s*\w|[.!?]\s+\w)/g, (m) => m.toUpperCase());
expect(await runText(page, 1, id)).toBe(expected);
});
test("change case is undoable - undo restores the original text", async ({
page,
}) => {
await open(page, 1);
const id = await runId(page, 1, "Comprehensive\\s+toolkit");
const orig = await runText(page, 1, id);
await selectRun(page, id);
await page.getByTestId("pdf-editor-change-case").click();
await page.getByTestId("pdf-editor-change-case-upper").click();
await page.waitForTimeout(400);
expect(await runText(page, 1, id)).toBe(orig.toUpperCase());
await page.getByTestId("pdf-editor-undo").click();
await page.waitForTimeout(400);
expect(await runText(page, 1, id)).toBe(orig);
});
test("duplicate (Ctrl+D) clones the selected run", async ({ page }) => {
await open(page, 1);
const id = await runId(page, 1, "Comprehensive\\s+toolkit");
const before = await totalRuns(page);
await selectRun(page, id);
await page.evaluate(() =>
(document.activeElement as HTMLElement | null)?.blur(),
);
await page.keyboard.press("Control+d");
await page.waitForTimeout(300);
expect(await totalRuns(page), "duplicate adds one run").toBe(before + 1);
});
test("Delete key removes the selected run", async ({ page }) => {
await open(page, 1);
const id = await runId(page, 1, "Comprehensive\\s+toolkit");
const before = await totalRuns(page);
await selectRun(page, id);
await page.evaluate(() =>
(document.activeElement as HTMLElement | null)?.blur(),
);
await page.keyboard.press("Delete");
await page.waitForTimeout(300);
expect(await totalRuns(page), "delete removes one run").toBe(before - 1);
expect(await runText(page, 1, id)).toBe("(gone)");
});
test("undo restores a locked run to unlocked + editable", async ({
page,
}) => {
await open(page, 1);
const id = await runId(page, 1, "Comprehensive\\s+toolkit");
await selectRun(page, id);
await page.getByTestId("pdf-editor-toggle-lock").click();
await expect(page.getByTestId(`pdf-editor-run-${id}`)).toHaveAttribute(
"data-locked",
"true",
);
await page.getByTestId("pdf-editor-undo").click();
await page.waitForTimeout(250);
const locked = await page.evaluate(
(rid: string) =>
(window as unknown as EditorTestWindow).__editor_store.doc
.page(1)
.runs.find((x) => x.id === rid)!.locked,
id,
);
expect(locked, "undo unlocks the run").toBe(false);
await expect(page.getByTestId(`pdf-editor-run-${id}`)).toHaveAttribute(
"contenteditable",
"true",
);
});
test("find (Ctrl+F) reports a match count for an existing term", async ({
page,
}) => {
await open(page, 1);
await page.keyboard.press("Control+f");
await expect(page.getByTestId("pdf-editor-find-bar")).toBeVisible();
await page.getByTestId("pdf-editor-find-input").fill("PDF");
await page.waitForTimeout(400);
const count = await page.getByTestId("pdf-editor-find-count").innerText();
expect(count, "find reports N of M for a present term").toMatch(
/\d+ of \d+/,
);
});
test("replace swaps the matched run's text for the new term", async ({
page,
}) => {
await open(page, 1);
const before = await countRunsContaining(page, "toolkit");
expect(before, "fixture must contain the search term").toBeGreaterThan(0);
await page.keyboard.press("Control+f");
await expect(page.getByTestId("pdf-editor-find-bar")).toBeVisible();
await page.getByTestId("pdf-editor-find-input").fill("toolkit");
await page.waitForTimeout(400);
await page.getByTestId("pdf-editor-replace-input").fill("widget");
await page.getByTestId("pdf-editor-replace-one").click();
await page.waitForTimeout(600);
expect(
await countRunsContaining(page, "toolkit"),
"one match-run replaced",
).toBe(before - 1);
expect(
await countRunsContaining(page, "widget"),
"replacement text present",
).toBeGreaterThan(0);
});
test("replace all rewrites every matching run", async ({ page }) => {
await open(page, 1);
const before = await countRunsContaining(page, "pdf");
expect(before, "fixture must contain the search term").toBeGreaterThan(0);
await page.keyboard.press("Control+f");
await expect(page.getByTestId("pdf-editor-find-bar")).toBeVisible();
await page.getByTestId("pdf-editor-find-input").fill("PDF");
await page.waitForTimeout(400);
await page.getByTestId("pdf-editor-replace-input").fill("DOC");
await page.getByTestId("pdf-editor-replace-all").click();
await page.waitForTimeout(900);
expect(
await countRunsContaining(page, "pdf"),
"no matches remain after replace-all",
).toBe(0);
});
});
@@ -0,0 +1,333 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import {
DisplayTransform,
type DisplayTransformData,
} from "@app/tools/pdfTextEditor/model/DisplayTransform";
import path from "path";
// Regression for the CropBox/rotation positioning bug (root-caused on
// spirit-sx-user-guide.pdf, which is NEVER committed).
interface Probe {
width: number;
height: number;
runCount: number;
matrixE: number;
matrixF: number;
boundsX: number;
display: DisplayTransformData;
}
async function load(
page: import("@playwright/test").Page,
name: string,
): Promise<Probe> {
await page.goto("/pdf-text-editor?charcodeStrategy=content-stream", {
waitUntil: "domcontentloaded",
});
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(
path.join(import.meta.dirname, `../test-fixtures/${name}.pdf`),
);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(800);
return page.evaluate(() => {
const s = (
window as unknown as {
__editor_store: {
doc: {
page: (i: number) => {
width: number;
height: number;
runs: Array<{
bounds: { x: number };
matrix: { e: number; f: number };
}>;
display: Probe["display"];
};
};
};
}
).__editor_store;
const pg = s.doc.page(0);
const r = pg.runs[0];
return {
width: pg.width,
height: pg.height,
runCount: pg.runs.length,
matrixE: r?.matrix.e,
matrixF: r?.matrix.f,
boundsX: r?.bounds.x,
display: pg.display,
};
}) as Promise<Probe>;
}
test("control fixture (CropBox==MediaBox) yields an identity transform", async ({
page,
}) => {
const p = await load(page, "cropbox-control");
expect(p.width).toBe(400);
expect(p.height).toBe(400);
expect(p.display.cropLeft).toBe(0);
expect(p.display.cropBottom).toBe(0);
expect(p.display.rotate).toBe(0);
const t = DisplayTransform.fromData(p.display);
expect(t.isIdentity).toBe(true);
// Model is raw; with identity the display position equals the raw position.
expect(t.apply(p.boundsX, p.matrixF)).toEqual({ x: p.boundsX, y: p.matrixF });
});
test("CropBox-offset fixture: page sized to CropBox, model raw, overlay offset", async ({
page,
}) => {
const p = await load(page, "cropbox-offset");
// page dims are the CropBox (visible) size, not the square MediaBox.
expect(p.width).toBe(300);
expect(p.height).toBe(350);
// The transform read the real PDF's CropBox origin.
expect(p.display.cropLeft).toBe(50);
expect(p.display.cropBottom).toBe(30);
expect(p.display.rotate).toBe(0);
// The MODEL stays in raw PDF (MediaBox) space - Td(60,350) baseline intact.
expect(p.matrixE).toBeCloseTo(60, 1);
expect(p.matrixF).toBeCloseTo(350, 1);
// The display anchor subtracts the CropBox origin: raw (~61.8,350) -> (~11.8,320).
const t = DisplayTransform.fromData(p.display);
const disp = t.apply(p.boundsX, p.matrixF);
expect(disp.x).toBeCloseTo(p.boundsX - 50, 3);
expect(disp.y).toBeCloseTo(320, 3);
// ...and the anchor now lands INSIDE the visible page (the bug put it past
// the right edge / above the top because the +50/-30 offset wasn't removed).
expect(disp.x).toBeGreaterThanOrEqual(0);
expect(disp.x).toBeLessThanOrEqual(p.width);
expect(disp.y).toBeGreaterThanOrEqual(0);
expect(disp.y).toBeLessThanOrEqual(p.height);
// Teeth: the un-transformed (pre-fix) x carried the +50 crop offset.
expect(p.boundsX).toBeGreaterThan(disp.x + 40);
});
test("CropBox + Rotate 90 fixture: dims swap, rotation in the transform, model raw", async ({
page,
}) => {
const p = await load(page, "cropbox-rotate90");
// /Rotate 90 swaps the displayed page dimensions.
expect(p.width).toBe(350);
expect(p.height).toBe(300);
expect(p.display.rotate).toBe(1);
expect(p.display.cropLeft).toBe(50);
expect(p.display.cropBottom).toBe(30);
// 90 CW affine is a proper rotation (det +1): a=0,b=-1,c=1,d=0.
expect([p.display.a, p.display.b, p.display.c, p.display.d]).toEqual([
0, -1, 1, 0,
]);
expect(
p.display.a * p.display.d - p.display.b * p.display.c,
"rotation must be det +1, not a reflection",
).toBeCloseTo(1, 9);
// Model still raw.
expect(p.matrixF).toBeCloseTo(350, 1);
// The display anchor lands inside the rotated visible page.
const t = DisplayTransform.fromData(p.display);
const disp = t.apply(p.boundsX, p.matrixF);
expect(disp.x).toBeGreaterThanOrEqual(0);
expect(disp.x).toBeLessThanOrEqual(p.width);
expect(disp.y).toBeGreaterThanOrEqual(0);
expect(disp.y).toBeLessThanOrEqual(p.height);
});
test("editing text on a Rotate-90 page applies cleanly and keeps placement in-bounds", async ({
page,
}) => {
// Editing happens in raw PDF space (commands are rotation-agnostic); the
// overlay maps the anchor through the rotation transform.
const errs: string[] = [];
page.on("pageerror", (e) => errs.push(e.message));
await page.goto("/pdf-text-editor?charcodeStrategy=content-stream", {
waitUntil: "domcontentloaded",
});
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(
path.join(import.meta.dirname, "../test-fixtures/cropbox-rotate90.pdf"),
);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(800);
const id = await page.evaluate(() => {
const s = (
window as unknown as {
__editor_store: {
doc: { page: (i: number) => { runs: Array<{ id: string }> } };
};
}
).__editor_store;
return s.doc.page(0).runs[0]?.id ?? "";
});
expect(id).toMatch(/^p0-/);
await page.locator(`[data-testid="pdf-editor-run-${id}"]`).click();
await page.waitForTimeout(150);
await page.evaluate((rid) => {
const el = document.querySelector<HTMLDivElement>(
`[data-testid="pdf-editor-run-${rid}"]`,
)!;
el.focus();
const sel = window.getSelection()!;
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
document.execCommand("insertText", false, "Z");
}, id);
await page.waitForTimeout(150);
await page.evaluate(
(rid) =>
document
.querySelector<HTMLElement>(`[data-testid="pdf-editor-run-${rid}"]`)
?.blur(),
id,
);
await page.waitForTimeout(800);
const after = await page.evaluate(() => {
const s = (
window as unknown as {
__editor_store: {
doc: {
page: (i: number) => {
width: number;
height: number;
runs: Array<{
id: string;
text: string;
bounds: { x: number };
matrix: { f: number };
}>;
display: {
a: number;
b: number;
c: number;
d: number;
e: number;
f: number;
};
};
};
};
}
).__editor_store;
const pg = s.doc.page(0);
const r = pg.runs[0];
const d = pg.display;
return {
text: r.text,
width: pg.width,
height: pg.height,
dispX: d.a * r.bounds.x + d.c * r.matrix.f + d.e,
dispY: d.b * r.bounds.x + d.d * r.matrix.f + d.f,
};
});
expect(errs, `no page errors:\n${errs.join("\n")}`).toEqual([]);
expect(after.text).toContain("Z"); // edit applied
// Anchor still inside the rotated visible page (no off-page drift).
expect(after.dispX).toBeGreaterThanOrEqual(0);
expect(after.dispX).toBeLessThanOrEqual(after.width);
expect(after.dispY).toBeGreaterThanOrEqual(0);
expect(after.dispY).toBeLessThanOrEqual(after.height);
});
test("CropBox-offset: the rendered glyph pixels overlap the run overlay box", async ({
page,
}) => {
// End-to-end: the PDFium-rendered bitmap (CropBox-cropped) and the HTML
// overlay (positioned via the transform) must agree on where "Hi" sits.
await page.goto("/pdf-text-editor?charcodeStrategy=content-stream", {
waitUntil: "domcontentloaded",
});
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(
path.join(import.meta.dirname, "../test-fixtures/cropbox-offset.pdf"),
);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(1200);
const result = await page.evaluate(() => {
const pageEl = document.querySelector<HTMLElement>(
'[data-testid="pdf-editor-page-0"]',
)!;
const canvas = pageEl.querySelector("canvas")!;
const pageRect = pageEl.getBoundingClientRect();
const ctx = canvas.getContext("2d")!;
const img = ctx.getImageData(0, 0, canvas.width, canvas.height);
// Find the dark-pixel bounding box (the "Hi" glyphs) in canvas px.
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
const { data, width, height } = img;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const o = (y * width + x) * 4;
const lum = (data[o] + data[o + 1] + data[o + 2]) / 3;
if (lum < 128 && data[o + 3] > 32) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
// Canvas is rendered at devicePixelRatio*scale; normalise to CSS px by the
// canvas's own client size.
const sx = canvas.clientWidth / canvas.width;
const sy = canvas.clientHeight / canvas.height;
const glyph = {
left: minX * sx,
top: minY * sy,
right: maxX * sx,
bottom: maxY * sy,
};
const overlay = document
.querySelector<HTMLElement>('[data-testid^="pdf-editor-run-"]')!
.getBoundingClientRect();
const ov = {
left: overlay.left - pageRect.left,
top: overlay.top - pageRect.top,
right: overlay.right - pageRect.left,
bottom: overlay.bottom - pageRect.top,
};
return { glyph, ov, found: maxX >= minX };
});
expect(result.found).toBe(true);
// The overlay box and the rendered-glyph box must overlap.
const overlaps =
result.ov.left <= result.glyph.right + 8 &&
result.ov.right >= result.glyph.left - 8 &&
result.ov.top <= result.glyph.bottom + 12 &&
result.ov.bottom >= result.glyph.top - 12;
expect(
overlaps,
`overlay ${JSON.stringify(result.ov)} must overlap glyph ${JSON.stringify(result.glyph)}`,
).toBe(true);
});
@@ -0,0 +1,91 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page, Route } from "@playwright/test";
import path from "path";
import type { EditorTestWindow } from "@app/tests/stubbed/editorTestTypes";
/** Cross-font charcode disambiguation (H1H2/U). */
const SUBSET = path.join(
import.meta.dirname,
"../test-fixtures/subset-font-sample.pdf",
);
test("editor sends the run's font name to encode-charcodes", async ({
page,
}: {
page: Page;
}) => {
test.setTimeout(90_000);
const bodies: Array<Record<string, unknown>> = [];
await page.route("**/encode-charcodes", async (route: Route) => {
try {
bodies.push(route.request().postDataJSON() as Record<string, unknown>);
} catch {
/* ignore non-JSON */
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ charcodes: [65], missing: [], note: "stub" }),
});
});
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 20_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(SUBSET);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(800);
// Edit a run - the cache-miss prefetch (and focus prewarm) POST to the
// endpoint, now carrying the resolved font's name.
const id = await page.evaluate(() => {
const s = (window as unknown as EditorTestWindow).__editor_store;
return s.doc.page(0).runs[0]?.id ?? null;
});
expect(id, "page 0 has a run").toBeTruthy();
await page.evaluate((rid: string) => {
const el = document.querySelector<HTMLDivElement>(
`[data-testid="pdf-editor-run-${rid}"]`,
)!;
el.focus();
const sel = window.getSelection()!;
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
document.execCommand("insertText", false, "s");
}, id as string);
await page.waitForTimeout(1500);
expect(bodies.length, "endpoint was called").toBeGreaterThan(0);
const named = bodies.filter(
(b) => typeof b.fontName === "string" && (b.fontName as string).length > 0,
);
expect(
named.length,
`at least one request carries a non-empty fontName; bodies=${JSON.stringify(
bodies.map((b) => b.fontName),
)}`,
).toBeGreaterThan(0);
// The program-bytes hash must ride along too: PDFium reports every
// "ABCDEF+Family" subset as bare "Family".
const hashed = bodies.filter(
(b) =>
typeof b.fontSha256 === "string" &&
/^[0-9a-f]{64}$/.test(b.fontSha256 as string),
);
expect(
hashed.length,
`at least one request carries a 64-hex fontSha256; bodies=${JSON.stringify(
bodies.map((b) => b.fontSha256),
)}`,
).toBeGreaterThan(0);
});
@@ -0,0 +1,203 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import path from "path";
// Editing a document twice is not the same as editing it once. The second edit
// starts from a REGENERATED page, so anything the generator dropped or reshaped
// on the first save is what the second edit builds on. These pin that a second
// round-trip is as safe as the first, on the page shapes most likely to suffer:
// a shading-backed page, a page whose /Contents is an array split mid-operator,
// a form XObject page, and an ordinary paragraph page.
const CASES: Array<{ name: string; file: string; needle: string }> = [
{
name: "shading page keeps its artwork",
file: "shading-sample.pdf",
needle: "Text over a gradient",
},
{
name: "split /Contents array survives",
file: "split-contents-sample.pdf",
needle: "Split contents line",
},
{
name: "form xobject page survives",
file: "form-xobject-sample.pdf",
needle: "",
},
{ name: "paragraph page survives", file: "paragraph-sample.pdf", needle: "" },
];
const PAGE_TEXT = () =>
(
window as unknown as {
__editor_store: {
state: { pages: { runs: { text: string }[] }[] };
};
}
).__editor_store.state.pages[0].runs
.map((r) => r.text)
.join("");
const FIRST_RUN = () => {
const runs = (
window as unknown as {
__editor_store: {
state: { pages: { runs: { id: string; text: string }[] }[] };
};
}
).__editor_store.state.pages[0].runs;
const r = runs.find((x) => x.text.trim().length > 3) ?? runs[0];
return r ? { id: r.id, text: r.text } : null;
};
/** Pixels that are neither near-white nor near-grey: the page's colour artwork. */
const COLOURED_PIXELS = () => {
const canvas = document.querySelector<HTMLCanvasElement>(
'[data-testid="pdf-editor-page-0"] canvas',
);
if (!canvas) return 0;
const ctx = canvas.getContext("2d");
if (!ctx) return 0;
const d = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
let n = 0;
for (let i = 0; i < d.length; i += 4) {
const mx = Math.max(d[i], d[i + 1], d[i + 2]);
const mn = Math.min(d[i], d[i + 1], d[i + 2]);
if (mx - mn > 18) n += 1;
}
return n;
};
async function appendChar(
page: import("@playwright/test").Page,
runId: string,
ch: string,
) {
await page.evaluate(
({ id, ch }) => {
const el = document.querySelector<HTMLElement>(
`[data-testid="pdf-editor-run-${id}"]`,
);
if (!el) throw new Error("run missing");
el.focus();
const sel = window.getSelection();
if (!sel) throw new Error("no selection api");
let node: Node = el;
while (node.lastChild) node = node.lastChild;
const range = document.createRange();
if (node.nodeType === Node.TEXT_NODE) {
range.setStart(node, (node.textContent ?? "").length);
} else {
range.selectNodeContents(el);
range.collapse(false);
}
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
document.execCommand("insertText", false, ch);
},
{ id: runId, ch },
);
await page.waitForTimeout(450);
}
async function saveAndReopen(
page: import("@playwright/test").Page,
tag: string,
) {
const downloaded = page.waitForEvent("download", { timeout: 30_000 });
await page.getByTestId("pdf-editor-download").click();
const confirm = page.getByTestId("pdf-editor-save-risk-confirm");
if (await confirm.isVisible().catch(() => false)) await confirm.click();
const saved = `test-results/double-edit-${tag}.pdf`;
await (await downloaded).saveAs(saved);
await page.evaluate(() => {
const w = window as unknown as {
__editor_store?: { document: unknown };
__prev_document?: unknown;
};
w.__prev_document = w.__editor_store?.document;
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(saved);
await page.waitForFunction(
() => {
const w = window as unknown as {
__editor_store?: {
document: unknown;
state: { pages: { runs: unknown[] }[] };
};
__prev_document?: unknown;
};
const s = w.__editor_store;
if (!s?.document || s.document === w.__prev_document) return false;
return (s.state.pages[0]?.runs.length ?? 0) > 0;
},
undefined,
{ timeout: 30_000 },
);
await page.waitForTimeout(1200);
}
const strip = (s: string) => s.replace(/\s+/g, "");
test.describe("PDF text editor - a second edit is as safe as the first", () => {
for (const c of CASES) {
test(c.name, async ({ page }) => {
test.setTimeout(240_000);
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 15_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(
path.join(import.meta.dirname, "../test-fixtures", c.file),
);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(1800);
const loadedColour = (await page.evaluate(COLOURED_PIXELS)) as number;
for (const pass of [1, 2]) {
const run = (await page.evaluate(FIRST_RUN)) as {
id: string;
text: string;
} | null;
expect(
run,
`${c.name}: no editable run before pass ${pass}`,
).not.toBeNull();
await appendChar(page, run!.id, String(pass));
const beforeSave = (await page.evaluate(PAGE_TEXT)) as string;
await saveAndReopen(page, `${c.name.replace(/\W+/g, "-")}-${pass}`);
const afterReopen = (await page.evaluate(PAGE_TEXT)) as string;
expect(
strip(afterReopen).length,
`${c.name}: pass ${pass} lost text across save+reopen`,
).toBe(strip(beforeSave).length);
if (c.needle) {
expect(
afterReopen,
`${c.name}: pass ${pass} lost the original words`,
).toContain(c.needle);
}
// Colour artwork (a gradient, a pattern) must not drain away. The
// second pass is the one that historically loses a background.
if (loadedColour > 1000) {
const now = (await page.evaluate(COLOURED_PIXELS)) as number;
expect(
now / loadedColour,
`${c.name}: pass ${pass} lost the page's colour artwork`,
).toBeGreaterThan(0.9);
}
}
});
}
});
@@ -0,0 +1,169 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import path from "path";
import type { EditorTestWindow } from "@app/tests/stubbed/editorTestTypes";
/**
* Direct manipulation: grab a box's frame to move it.
*
* The gesture used to require Ctrl, which nothing on the page advertised - the
* sidebar carried a permanent instruction card instead. Ctrl still works, but
* the frame is now the discoverable path.
*
* There is deliberately no drag-to-resize: re-wrapping runs through
* ReflowWrapCommand, whose x-gap word grouping splits inside words on runs
* with individually positioned glyphs. The last test here pins that down so
* the handle is not reintroduced before the grouping is fixed.
*/
const SAMPLE = path.join(
import.meta.dirname,
"../../../../public/samples/Sample.pdf",
);
async function open(page: Page): Promise<void> {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 15_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(SAMPLE);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(900);
}
interface Shape {
x: number;
y: number;
width: number;
}
async function shapeOf(page: Page, src: string): Promise<Shape> {
const out = await page.evaluate((needle: string) => {
const run = (window as unknown as EditorTestWindow).__editor_store.doc
.page(0)
.runs.find((r) => new RegExp(needle).test(r.text));
if (!run) return null;
return {
x: run.bounds.x,
y: run.bounds.y,
width: run.bounds.width,
};
}, src);
if (!out) throw new Error(`run /${src}/ not found`);
return out;
}
async function boxOf(page: Page, src: string) {
const id = await page.evaluate((needle: string) => {
const run = (window as unknown as EditorTestWindow).__editor_store.doc
.page(0)
.runs.find((r) => new RegExp(needle).test(r.text));
return run ? run.id : null;
}, src);
if (!id) throw new Error(`run /${src}/ not found`);
const locator = page.locator(`[data-testid="pdf-editor-run-${id}"]`);
const box = await locator.boundingBox();
if (!box) throw new Error(`run /${src}/ has no box`);
return box;
}
test.describe("PDF text editor - edge gestures", () => {
test("dragging the frame moves the box, with no modifier held", async ({
page,
}) => {
await open(page);
const before = await shapeOf(page, "Downloads");
const box = await boxOf(page, "Downloads");
// Grab the top edge - the frame, not the text interior.
await page.mouse.move(box.x + box.width / 2, box.y + 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 40, box.y + 2, { steps: 8 });
await page.mouse.up();
await page.waitForTimeout(400);
const after = await shapeOf(page, "Downloads");
expect(
Math.abs(after.x - before.x),
"a frame drag must move the run on the page",
).toBeGreaterThan(5);
});
test("clicking the text interior still types instead of moving", async ({
page,
}) => {
await open(page);
const before = await shapeOf(page, "Downloads");
const box = await boxOf(page, "Downloads");
// Well inside the box: this is the caret, not a handle.
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
await page.waitForTimeout(300);
const after = await shapeOf(page, "Downloads");
expect(Math.abs(after.x - before.x)).toBeLessThan(1);
expect(Math.abs(after.y - before.y)).toBeLessThan(1);
});
test("Ctrl+drag from the interior still moves, for existing muscle memory", async ({
page,
}) => {
await open(page);
const before = await shapeOf(page, "Downloads");
const box = await boxOf(page, "Downloads");
await page.keyboard.down("Control");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 40, box.y + box.height / 2, {
steps: 8,
});
await page.mouse.up();
await page.keyboard.up("Control");
await page.waitForTimeout(400);
const after = await shapeOf(page, "Downloads");
expect(Math.abs(after.x - before.x)).toBeGreaterThan(5);
});
test("a frame drag never rewrites the run's text", async ({ page }) => {
await open(page);
const textOf = (needle: string) =>
page.evaluate(
(n: string) =>
(window as unknown as EditorTestWindow).__editor_store.doc
.page(0)
.runs.find((r) => new RegExp(n).test(r.text))?.text ?? "",
needle,
);
const before = await textOf("Open Source");
const box = await boxOf(page, "Open Source");
// Straight at the right-hand edge - where a resize handle would have been.
await page.mouse.move(box.x + box.width - 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width * 0.5, box.y + box.height / 2, {
steps: 10,
});
await page.mouse.up();
await page.waitForTimeout(600);
// Moving must never reflow. A resize here used to shred the run into
// one character per line.
expect(await textOf("Open Source")).toBe(before);
});
test("the insert verbs live in the panel, not the canvas strip", async ({
page,
}) => {
await open(page);
const panel = page.locator('[data-sidebar="tool-panel"]');
await expect(panel.getByTestId("pdf-editor-add-text")).toBeVisible();
await expect(panel.getByTestId("pdf-editor-add-image")).toBeVisible();
await expect(
page.getByTestId("pdf-editor-toolbar").getByTestId("pdf-editor-add-text"),
).toHaveCount(0);
});
});
@@ -0,0 +1,96 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import path from "path";
test.describe("PDF text editor - editing surface", () => {
const openAndEdit = async (
page: import("@playwright/test").Page,
fixture: string,
) => {
await page.goto("/pdf-text-editor?charcodeStrategy=content-stream", {
waitUntil: "domcontentloaded",
});
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(
path.join(import.meta.dirname, `../test-fixtures/${fixture}.pdf`),
);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(1200);
const run = page.locator('[data-testid^="pdf-editor-run-p0-"]').first();
await run.click();
await page.keyboard.type("X");
await page.waitForTimeout(150);
return run;
};
const alphaOf = (css: string): number => {
const parts = (/rgba?\(([^)]+)\)/.exec(css)?.[1] ?? "")
.split(",")
.map((p) => parseFloat(p.trim()));
return parts.length === 4 ? parts[3] : 1;
};
test("editing does not cover the page with an opaque mask", async ({
page,
}) => {
const run = await openAndEdit(page, "sample");
const bg = await run.evaluate((el) => getComputedStyle(el).backgroundColor);
expect(alphaOf(bg)).toBeLessThan(0.5);
});
test("editing paints no glyphs of its own", async ({ page }) => {
const run = await openAndEdit(page, "sample");
const color = await run.evaluate((el) => getComputedStyle(el).color);
expect(color).toBe("rgba(0, 0, 0, 0)");
});
test("the typed character reaches the page itself", async ({ page }) => {
const run = await openAndEdit(page, "sample");
await expect(run).toContainText("X");
const model = await page.evaluate(() => {
const store = (
window as unknown as {
__editor_store: {
doc: { page(i: number): { runs: Array<{ text: string }> } };
};
}
).__editor_store;
return store.doc.page(0).runs[0]?.text ?? "";
});
expect(model).toContain("X");
});
test("a coloured page is not banded while editing", async ({ page }) => {
const run = await openAndEdit(page, "stirling-marketing");
const bg = await run.evaluate((el) => getComputedStyle(el).backgroundColor);
expect(alphaOf(bg)).toBeLessThan(0.5);
});
// The single keystroke above stayed under the mask's grace count. A real
// sentence does not: the overlay took its glyphs over mid-word and handed
// them back when the engine caught up, so the text visibly changed typeface
// while being typed and changed back afterwards. Typed tokens are re-priced
// onto the PDF's own advances every keystroke, so the caret tracks the page
// ink without the overlay ever having to paint over it.
test("typing a whole word never swaps in the overlay's own glyphs", async ({
page,
}) => {
const run = await openAndEdit(page, "sample");
const swaps: string[] = [];
for (let i = 0; i < 20; i++) {
await page.keyboard.type("a");
await page.waitForTimeout(60);
const colour = await run.evaluate((el) => getComputedStyle(el).color);
if (colour !== "rgba(0, 0, 0, 0)") swaps.push(`char ${i}: ${colour}`);
}
expect(
swaps.slice(0, 5),
"the run rendered in the overlay's fallback face instead of the PDF's",
).toEqual([]);
});
});
@@ -0,0 +1,272 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import path from "path";
// Two things the user sees go wrong while typing into a run, both because the
// overlay and the page bitmap disagree about where the text is:
//
// * Enter at the end of a line read back as TWO line breaks, and the caret,
// parked by the browser inside the empty token span it left behind, was
// lost on the next repaint - so the next character landed at the top of
// the run instead of on the new line.
// * While the user types, the engine holds off re-measuring pen positions
// until typing pauses, so the overlay lays the new text out on the
// browser's advances while the page renders it on the PDF's. The caret
// walked off the glyphs, a fraction of a pixel per keystroke.
const PARAGRAPH_PDF = path.join(
import.meta.dirname,
"../test-fixtures/paragraph-sample.pdf",
);
const MUSHROOM_PDF = path.join(
import.meta.dirname,
"../test-fixtures/mushroom-life.pdf",
);
async function openEditor(page: import("@playwright/test").Page, file: string) {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(file);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 60_000,
});
await page.waitForTimeout(1500);
}
function modelTextOf(page: import("@playwright/test").Page, testId: string) {
return page.evaluate((id) => {
const w = window as unknown as {
__editor_store: {
state: { pages: { runs: { id: string; text: string }[] }[] };
};
};
for (const p of w.__editor_store.state.pages) {
for (const r of p.runs)
if (`pdf-editor-run-${r.id}` === id) return r.text;
}
return "";
}, testId);
}
/** Put the caret `offset` characters into the run's `index`-th painted line. */
async function caretInLine(
page: import("@playwright/test").Page,
testId: string,
index: number,
offset: number,
) {
await page.evaluate(
({ id, index, offset }) => {
const el = document.querySelector<HTMLDivElement>(
`[data-testid="${id}"]`,
);
if (!el) throw new Error(`no run ${id}`);
el.focus();
const scope = (el.children[index] as HTMLElement) ?? el;
const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT);
let seen = 0;
let node = walker.nextNode();
while (node) {
const length = (node.nodeValue ?? "").length;
if (seen + length >= offset) {
const range = document.createRange();
range.setStart(node, offset - seen);
range.collapse(true);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
return;
}
seen += length;
node = walker.nextNode();
}
throw new Error("offset past the end of the line");
},
{ id: testId, index, offset },
);
}
// The caret's x, and the right edge of the glyphs the user can actually see -
// the overlay's own text while it is unmasked, the page bitmap while it is not.
function caretVersusGlyphs(
page: import("@playwright/test").Page,
testId: string,
) {
return page.evaluate((id) => {
const el = document.querySelector<HTMLDivElement>(`[data-testid="${id}"]`);
const selection = window.getSelection();
if (!el || !selection || selection.rangeCount === 0) return null;
const caret = selection.getRangeAt(0).getBoundingClientRect();
const focusNode = selection.focusNode;
const pristine = el.classList.contains("is-pristine");
if (!pristine && focusNode?.nodeType === Node.TEXT_NODE) {
const glyphs = document.createRange();
glyphs.selectNodeContents(focusNode);
return {
pristine,
gap: caret.left - glyphs.getBoundingClientRect().right,
};
}
// Transparent overlay: the glyphs on screen are the page's own bitmap, so
// read the rightmost inked pixel on the caret's row.
const canvas = el
.closest("[data-testid^='pdf-editor-page-']")
?.querySelector("canvas") as HTMLCanvasElement | null;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return null;
const box = canvas.getBoundingClientRect();
const sx = canvas.width / box.width;
const sy = canvas.height / box.height;
const top = Math.max(0, Math.floor((caret.top - box.top) * sy));
const bottom = Math.min(
canvas.height,
Math.ceil((caret.bottom - box.top) * sy),
);
const band = ctx.getImageData(
0,
top,
canvas.width,
Math.max(1, bottom - top),
);
let rightmost = -1;
for (let y = 0; y < band.height; y += 1) {
for (let x = canvas.width - 1; x > rightmost; x -= 1) {
const i = (y * canvas.width + x) * 4;
if (band.data[i] < 160 && band.data[i + 1] < 160) {
rightmost = x;
break;
}
}
}
if (rightmost < 0) return null;
return { pristine, gap: caret.left - (box.left + rightmost / sx) };
}, testId);
}
test.describe("PDF text editor - Enter keeps the caret on the new line", () => {
test("Enter at the end of a line adds ONE line and types onto it", async ({
page,
}) => {
await openEditor(page, PARAGRAPH_PDF);
const run = page
.locator('[data-testid^="pdf-editor-run-p0-"]')
.filter({ hasText: /First line of the body/ })
.first();
const testId = (await run.getAttribute("data-testid")) ?? "";
await run.click();
await page.waitForTimeout(300);
const before = await modelTextOf(page, testId);
const lines = before.split("\n");
expect(lines.length, "fixture should be a multi-line paragraph").toBe(4);
await caretInLine(page, testId, 1, lines[1].length);
await page.keyboard.press("Enter");
await page.waitForTimeout(2500);
const after = await modelTextOf(page, testId);
expect(after.split("\n"), "one Enter is one line break").toHaveLength(5);
expect(after.split("\n")[2]).toBe("");
// The next character has to land on the NEW line, not at the top of the
// run: the caret used to be dropped by the repaint that followed.
await page.keyboard.type("ZZ");
await page.waitForTimeout(1500);
expect((await modelTextOf(page, testId)).split("\n")[2]).toBe("ZZ");
});
test("Enter mid-line splits it and types onto the second half", async ({
page,
}) => {
await openEditor(page, PARAGRAPH_PDF);
const run = page
.locator('[data-testid^="pdf-editor-run-p0-"]')
.filter({ hasText: /First line of the body/ })
.first();
const testId = (await run.getAttribute("data-testid")) ?? "";
await run.click();
await page.waitForTimeout(300);
await caretInLine(page, testId, 1, 6); // "Second| line continues..."
await page.keyboard.press("Enter");
await page.waitForTimeout(2500);
await page.keyboard.type("ZZ");
await page.waitForTimeout(1500);
const after = (await modelTextOf(page, testId)).split("\n");
expect(after).toHaveLength(5);
expect(after[1]).toBe("Second");
expect(after[2].startsWith("ZZ")).toBe(true);
});
});
// Guards caret drift during a typing burst. The bitmap is never the culprit:
// PDFium re-rasterises in milliseconds. The engine's pen positions are, and a
// debounce that clears its timer on every dispatch never refreshes them, so
// the overlay falls back to browser advances - a percent wider - and the caret
// walks off the glyphs by the difference.
//
// Masking the page and letting the overlay paint its own glyphs also hides
// this, and is the wrong trade: it changes the typeface mid-word. See
// pdf-text-editor-edit-mask.spec.ts.
test.describe("PDF text editor - the caret stays on the text while typing", () => {
test("a long typing burst never separates the caret from the glyphs", async ({
page,
}) => {
await openEditor(page, MUSHROOM_PDF);
const run = page
.locator('[data-testid^="pdf-editor-run-p0-"]')
.filter({ hasText: /^Spore Stage$/ })
.first();
if ((await run.count()) === 0) {
test.skip(true, "fixture is missing the Spore Stage heading");
return;
}
const testId = (await run.getAttribute("data-testid")) ?? "";
await run.click();
await page.waitForTimeout(300);
await page.keyboard.press("End");
await page.waitForTimeout(400);
// Sampled DURING the burst, never after it: the point is that the caret
// tracks the page's own glyphs while the user is still typing. Each
// checkpoint takes the BEST of a few instantaneous reads: on a loaded
// runner a single read can catch the caret one raster behind (a
// glyph-width of transient lead), while the drift this guards against -
// stale pen positions - survives every refresh, so its gap never drops.
const gaps: number[] = [];
for (let i = 0; i < 30; i += 1) {
await page.keyboard.type("b", { delay: 0 });
await page.waitForTimeout(60);
if (i === 9 || i === 19 || i === 29) {
let best = Number.POSITIVE_INFINITY;
for (let poll = 0; poll < 6; poll += 1) {
const seen = await caretVersusGlyphs(page, testId);
expect(seen, "should be able to measure the caret").not.toBeNull();
best = Math.min(best, Math.abs(seen!.gap));
if (best < 4) break;
await page.waitForTimeout(50);
}
gaps.push(best);
}
}
// The gap used to GROW with every keystroke - 7px, 14px, 21px here.
for (const gap of gaps) {
expect(
gap,
`caret sat ${gap.toFixed(1)}px off the text (gaps: ${gaps.map((g) => g.toFixed(1)).join(", ")})`,
).toBeLessThan(4);
}
// And it must not have been quietly accumulating: after the burst the caret
// is no further off than it was during it.
await page.waitForTimeout(2500);
const after = await caretVersusGlyphs(page, testId);
expect(Math.abs(after!.gap)).toBeLessThan(4);
});
});
@@ -0,0 +1,156 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import path from "path";
// A line created with Enter came out in Helvetica while the paragraph around it
// kept the document's own face. Two things had to be true for that:
//
// 1. `emptySlot` stamped the new blank line `base14:...` the moment Enter was
// pressed - before a character had been typed into it - and everything
// typed afterwards re-emitted against that id.
// 2. The fresh-emit branch asked `bestFontPtrForText` for a font to reuse
// using the slot's OWN objects, and a brand-new line has none, so it got 0
// and fell through to the base-14 emit.
//
// Neither alone is enough: the blank line has to inherit the right id, AND the
// emit has to be able to find a real font handle behind it. Measured on
// Sample.pdf, typing "tion tions ration" onto a new line: 15 of 15 emitted
// objects were Helvetica before, 4 of 15 after.
//
// The residue is characters the embedded subset genuinely does not contain -
// Sample.pdf's paragraph has no lowercase "h" at all, so "the quick brown fox"
// still falls back for those. That needs the subset extending and is not what
// this test is about.
const SAMPLE = path.join(
import.meta.dirname,
"../../../../public/samples/Sample.pdf",
);
/** Every character of this is already in the fixture paragraph. */
const IN_SUBSET = "tion tions ration";
interface SlotView {
fontId: string;
text: string;
}
async function open(page: Page): Promise<void> {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(SAMPLE);
await expect(page.getByTestId("pdf-editor-page-1")).toBeVisible({
timeout: 60_000,
});
await page.waitForTimeout(1500);
}
async function findRun(page: Page): Promise<string> {
const id = await page.evaluate(() => {
const s = (
window as unknown as {
__editor_store: {
state: { pages: { runs: { id: string; text: string }[] }[] };
};
}
).__editor_store;
for (const p of s.state.pages) {
for (const r of p.runs) {
if (/Stirling\s+PDF\s+is\s+a\s+robust/.test(r.text)) return r.id;
}
}
return "";
});
expect(id, "fixture paragraph not found").not.toBe("");
return id;
}
function slotsOf(page: Page, runId: string): Promise<SlotView[] | null> {
return page.evaluate((rid: string) => {
const s = (
window as unknown as {
__editor_store: {
doc: {
loadedPages(): {
runs: {
id: string;
paragraphLineSlots?: {
fontId: string;
mergedFromTexts: string[];
}[];
}[];
}[];
};
};
}
).__editor_store;
for (const pg of s.doc.loadedPages()) {
const r = pg.runs.find((x) => x.id === rid);
if (r?.paragraphLineSlots) {
return r.paragraphLineSlots.map((sl) => ({
fontId: sl.fontId,
text: sl.mergedFromTexts.join(""),
}));
}
}
return null;
}, runId);
}
test.describe("PDF text editor - a new line keeps the document's font", () => {
test("Enter then typing does not stamp the line base-14", async ({
page,
}) => {
await open(page);
const runId = await findRun(page);
const run = page.locator(`[data-testid="pdf-editor-run-${runId}"]`);
await run.click();
await page.waitForTimeout(400);
const before = await slotsOf(page, runId);
expect(before, "paragraph should have line slots").not.toBeNull();
const paragraphFont = before![0].fontId;
expect(
paragraphFont.startsWith("base14:"),
`fixture paragraph is already base-14 (${paragraphFont}) - it proves nothing`,
).toBe(false);
// Caret to the end of the first painted line, then Enter and type.
await page.evaluate((rid: string) => {
const el = document.querySelector<HTMLElement>(
`[data-testid="pdf-editor-run-${rid}"]`,
)!;
const first = el.querySelector('[data-pdf-editor-line="0"]')!;
el.focus();
const sel = window.getSelection()!;
const range = document.createRange();
range.selectNodeContents(first);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
}, runId);
await page.waitForTimeout(300);
await page.keyboard.press("Enter");
await page.waitForTimeout(1000);
await page.keyboard.type(IN_SUBSET, { delay: 30 });
await page.waitForTimeout(2500);
const after = await slotsOf(page, runId);
expect(after, "slots vanished").not.toBeNull();
const typed = after!.find((sl) =>
sl.text.replace(/\s+/g, "").includes("tions"),
);
expect(
typed,
`the typed line is not in the slots: ${JSON.stringify(after!.map((s) => s.text.slice(0, 20)))}`,
).toBeTruthy();
expect(
typed!.fontId,
`the new line was emitted as ${typed!.fontId} while the paragraph is ${paragraphFont}`,
).toBe(paragraphFont);
});
});
@@ -0,0 +1,159 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import path from "path";
import type { EditorTestWindow } from "@app/tests/stubbed/editorTestTypes";
/** Focusing a run must not slide its words off the glyphs underneath. */
const SAMPLE = path.join(
import.meta.dirname,
"../test-fixtures/user-sample.pdf",
);
async function openSample(page: Page): Promise<void> {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 45_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(SAMPLE);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 45_000,
});
await page.waitForTimeout(800);
}
/** Id of the first multi-word run that carries captured positions. */
async function firstMeasuredRun(page: Page): Promise<string | null> {
return page.evaluate(() => {
const store = (window as unknown as EditorTestWindow).__editor_store;
for (const run of store.doc.page(0).runs) {
if (!run.charStartsX || run.charPositionsKey === null) continue;
if (!/\S\s+\S/.test(run.text)) continue;
// Exact placement is enabled for single-line runs only.
if ((run.paragraphLineCount ?? 1) > 1) continue;
return run.id;
}
return null;
});
}
test("the reader captures engine pen positions for page text", async ({
page,
}: {
page: Page;
}) => {
test.setTimeout(140_000);
await openSample(page);
const stats = await page.evaluate(() => {
const store = (window as unknown as EditorTestWindow).__editor_store;
const runs = store.doc.page(0).runs;
let measured = 0;
let monotonic = 0;
for (const r of runs) {
if (!r.charStartsX || !r.charEndsX) continue;
measured += 1;
const xs = r.charStartsX.filter((v) => Number.isFinite(v));
const ends = r.charEndsX.filter((v) => Number.isFinite(v));
// Every glyph must advance forward and have a non-negative width.
const ok =
xs.length > 0 &&
ends.length === xs.length &&
r.charStartsX.every(
(v, i) => !Number.isFinite(v) || (r.charEndsX as number[])[i] >= v,
);
if (ok) monotonic += 1;
}
return { total: runs.length, measured, monotonic };
});
expect(stats.total).toBeGreaterThan(0);
expect(stats.measured).toBeGreaterThan(0);
expect(stats.monotonic).toBe(stats.measured);
});
// The per-word boxes that used to tile here were invisible once the overlay
// started showing the page bitmap until the first edit, and an inline-block
// makes Home/End move within the WORD rather than the line - so every edit
// landed at the click point. These pin the behaviour that replaced them.
const EDITS: Array<{
name: string;
keys: (p: Page) => Promise<void>;
expect: (before: string) => string;
}> = [
{
name: "End then type appends",
keys: async (p) => {
await p.keyboard.press("End");
await p.keyboard.type("Z");
},
expect: (b) => b + "Z",
},
{
name: "Home then type prepends",
keys: async (p) => {
await p.keyboard.press("Home");
await p.keyboard.type("Z");
},
expect: (b) => "Z" + b,
},
{
name: "Home then Delete removes the first character",
keys: async (p) => {
await p.keyboard.press("Home");
await p.keyboard.press("Delete");
},
expect: (b) => b.slice(1),
},
];
for (const c of EDITS) {
test(`caret: ${c.name}`, async ({ page }: { page: Page }) => {
test.setTimeout(140_000);
await openSample(page);
const runId = await firstMeasuredRun(page);
expect(runId).toBeTruthy();
const overlay = page.locator(`[data-testid="pdf-editor-run-${runId}"]`);
// WebKit's innerText appends a trailing newline Chromium omits. Strip it
// on every read, or comparing before/after skews instead of matching.
const before = (await overlay.innerText())
.replace(/\u00a0/g, " ")
.replace(/\n+$/, "");
await overlay.click();
await page.waitForTimeout(150);
await c.keys(page);
await page.waitForTimeout(300);
const after = (await overlay.innerText())
.replace(/\u00a0/g, " ")
.replace(/\n+$/, "");
expect(after).toBe(c.expect(before));
});
}
test("typing leaves the text intact apart from the typed character", async ({
page,
}: {
page: Page;
}) => {
test.setTimeout(140_000);
await openSample(page);
const runId = await firstMeasuredRun(page);
expect(runId).toBeTruthy();
const overlay = page.locator(`[data-testid="pdf-editor-run-${runId}"]`);
const before = (await overlay.innerText())
.replace(/\u00a0/g, " ")
.replace(/\n+$/, "");
await overlay.click();
await page.waitForTimeout(120);
await page.keyboard.press("End");
await page.keyboard.type("Z");
await page.waitForTimeout(200);
const after = (await overlay.innerText())
.replace(/\u00a0/g, " ")
.replace(/\n+$/, "");
expect(after.replace("Z", "")).toBe(before);
expect(after).toContain("Z");
});
@@ -0,0 +1,331 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import path from "path";
// The fill colour picker (ColorInput, testid pdf-editor-colour) had three faults that
// all trace back to Mantine driving onChange from its own dropdown lifecycle:
//
// (A) `fixOnBlur` re-emitted the last valid colour when the input blurred, so
// clicking Undo re-dispatched SetColour after the undo landed and the text
// stayed recoloured (past the 600ms coalesce window).
// (B) that same re-emission made one committed pick cost two Ctrl+Z.
// (C) the saturation dropdown stayed portalled over the page, swallowing the
// next click on a run, and Escape did not dismiss it.
//
// Judged on the page bitmap, the history depth and hit-testing - not on the
// toolbar's own state.
const PARAGRAPH_PDF = path.join(
import.meta.dirname,
"../test-fixtures/paragraph-sample.pdf",
);
/** Heading run, and the 4-line body paragraph below it. */
const HEADING = "p0-t0";
const BODY = "p0-t1";
interface InkSample {
ink: number;
redDom: number;
core: [number, number, number];
}
interface InkWindow {
__ink: (runId: string) => InkSample | null;
}
// Counts the pixels under a run's client rect that are clearly red-dominant.
const INK_SAMPLER = `
window.__ink = function (runId) {
var el = document.querySelector('[data-testid="pdf-editor-run-' + runId + '"]');
if (!el) return null;
var pg = el.closest("[data-testid^='pdf-editor-page-']");
var canvas = pg && pg.querySelector("canvas");
if (!canvas || canvas.width < 2 || canvas.height < 2) return null;
var ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return null;
var cb = canvas.getBoundingClientRect();
var rb = el.getBoundingClientRect();
if (cb.width < 1 || rb.width < 1) return null;
var sx = canvas.width / cb.width;
var sy = canvas.height / cb.height;
var x0 = Math.max(0, Math.floor((rb.left - cb.left) * sx) - 4);
var y0 = Math.max(0, Math.floor((rb.top - cb.top) * sy) - 4);
var w = Math.min(canvas.width - x0, Math.ceil(rb.width * sx) + 8);
var h = Math.min(canvas.height - y0, Math.ceil(rb.height * sy) + 8);
if (w < 2 || h < 2) return null;
var d = ctx.getImageData(x0, y0, w, h).data;
var ink = 0, redDom = 0, best = -1, core = [255, 255, 255];
for (var i = 0; i < d.length; i += 4) {
var r = d[i], g = d[i + 1], b = d[i + 2];
var dist = 765 - (r + g + b);
if (dist <= 90) continue;
ink++;
if (r - g > 60 && r - b > 60) redDom++;
if (dist > best) { best = dist; core = [r, g, b]; }
}
return { ink: ink, redDom: redDom, core: core };
};
`;
async function openEditor(page: import("@playwright/test").Page, file: string) {
await page.addInitScript({ content: INK_SAMPLER });
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(file);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 60_000,
});
await page.waitForTimeout(1500);
}
async function sample(
page: import("@playwright/test").Page,
runId: string,
): Promise<InkSample> {
const s = await page.evaluate(
(id) => (window as unknown as InkWindow).__ink(id),
runId,
);
expect(s, `no readable canvas ink sample for ${runId}`).not.toBeNull();
return s as InkSample;
}
function history(
page: import("@playwright/test").Page,
): Promise<{ undo: number; redo: number }> {
return page.evaluate(() =>
(
window as unknown as {
__editor_store: {
history: { size(): { undo: number; redo: number } };
};
}
).__editor_store.history.size(),
);
}
function selectedRunIds(
page: import("@playwright/test").Page,
): Promise<string[]> {
return page.evaluate(
() =>
(
window as unknown as {
__editor_store: { selection: { value: { runIds: string[] } } };
}
).__editor_store.selection.value.runIds,
);
}
/** A run's model fill, as an "r,g,b" string. */
function modelFill(
page: import("@playwright/test").Page,
runId: string,
): Promise<string> {
return page.evaluate((id) => {
const w = window as unknown as {
__editor_store: {
state: {
pages: {
runs: { id: string; fill: { r: number; g: number; b: number } }[];
}[];
};
};
};
for (const p of w.__editor_store.state.pages) {
const run = p.runs.find((r) => r.id === id);
if (run) return `${run.fill.r},${run.fill.g},${run.fill.b}`;
}
return "missing";
}, runId);
}
/** Class name of whatever actually sits on top at a run's centre point. */
function topmostAt(
page: import("@playwright/test").Page,
runId: string,
): Promise<string> {
return page.evaluate((id) => {
const el = document.querySelector(`[data-testid="pdf-editor-run-${id}"]`);
if (!el) return "missing";
const r = el.getBoundingClientRect();
const hit = document.elementFromPoint(
r.left + r.width / 2,
r.top + r.height / 2,
);
if (!hit) return "none";
return `${hit.tagName}.${typeof hit.className === "string" ? hit.className : ""}`;
}, runId);
}
async function selectRun(page: import("@playwright/test").Page, runId: string) {
await page.locator(`[data-testid="pdf-editor-run-${runId}"]`).click();
await page.waitForTimeout(350);
await expect(page.getByTestId("pdf-editor-colour")).toBeEnabled();
}
async function setFill(page: import("@playwright/test").Page, hex: string) {
const colour = page.getByTestId("pdf-editor-colour");
await colour.fill(hex);
await colour.press("Enter");
}
test.describe("PDF text editor - fill colour picker", () => {
// (A) + (B). The picker was still open when Undo was clicked; the blur
// re-applied the colour, so the undo popped a no-op step and the text stayed
// red - and dismissing the picker on its own left a second undo entry.
test("a colour pick is one undo step, and undo removes the colour", async ({
page,
}) => {
await openEditor(page, PARAGRAPH_PDF);
await selectRun(page, HEADING);
const clean = await history(page);
await setFill(page, "#cc0000"); // theme-allow-color PDF ink, matched against the rendered bitmap
await page.waitForTimeout(1200);
const red = await sample(page, HEADING);
const redFill = await modelFill(page, HEADING);
const afterPick = await history(page);
expect(
red.redDom,
`the pick must land first: redDom=${red.redDom} fill=${redFill}`,
).toBeGreaterThan(200);
// (A) Dwell well past the 600ms coalesce window, then undo straight from
// the picker - the click that reaches Undo also dismisses the dropdown.
await page.waitForTimeout(1500);
await page.getByTestId("pdf-editor-undo").click();
await page.waitForTimeout(1500);
const after = await sample(page, HEADING);
const afterFill = await modelFill(page, HEADING);
const afterUndo = await history(page);
expect(
after.redDom,
`undo must clear the red pixels (redDom ${red.redDom} -> ${after.redDom}, fill ${redFill} -> ${afterFill}, history ${afterPick.undo}/${afterPick.redo} -> ${afterUndo.undo}/${afterUndo.redo})`,
).toBeLessThan(20);
expect(afterFill, "undo must restore the original fill").not.toBe(redFill);
expect(afterUndo.undo, "one undo must empty the stack again").toBe(
clean.undo,
);
// (B) Pick again and dismiss the picker deliberately: dismissing must not
// bank a second entry on top of the pick.
await selectRun(page, HEADING);
await setFill(page, "#cc0000"); // theme-allow-color PDF ink, matched against the rendered bitmap
await page.waitForTimeout(1200);
const secondPick = await history(page);
await page.getByTestId("pdf-editor-colour").blur();
await page.waitForTimeout(1200);
const afterDismiss = await history(page);
expect(
afterDismiss.undo - clean.undo,
`one colour pick must stay one undo entry (after pick ${secondPick.undo}, after dismiss ${afterDismiss.undo})`,
).toBe(1);
});
// (C) The saturation dropdown is portalled over the top of the page, so a
// committed pick has to close it or it eats the next click on a run.
test("the picker does not sit over the page after a pick", async ({
page,
}) => {
await openEditor(page, PARAGRAPH_PDF);
await selectRun(page, BODY);
await setFill(page, "#cc0000"); // theme-allow-color PDF ink, matched against the rendered bitmap
await page.waitForTimeout(1200);
// The heading sits directly under where the dropdown opens.
const onTop = await topmostAt(page, HEADING);
expect(
onTop,
"nothing from the colour picker may cover the page after a pick",
).not.toContain("ColorInput");
// ...so one click - not a priming click - selects that run.
await page
.locator(`[data-testid="pdf-editor-run-${HEADING}"]`)
.click({ timeout: 5_000 });
await page.waitForTimeout(500);
expect(
await selectedRunIds(page),
"the first click after a colour pick must reach the run",
).toEqual([HEADING]);
});
// The "don't re-apply the same colour" guard is keyed on the selection's own
// fill, which is null for a MIXED selection - so dragging in the dropdown
// still unifies one. This is also the only test here that picks with the
// mouse instead of the text field.
test("a dropdown pick still unifies a mixed selection", async ({ page }) => {
await openEditor(page, PARAGRAPH_PDF);
await selectRun(page, HEADING);
await setFill(page, "#cc0000"); // theme-allow-color PDF ink, matched against the rendered bitmap
await page.waitForTimeout(1200);
expect(
(await sample(page, HEADING)).redDom,
"the heading must be red first",
).toBeGreaterThan(200);
// Heading (red) + body (black): a mixed fill, so the picker falls back to
// #000000 and reports nothing as the selection's colour.
await page.locator(`[data-testid="pdf-editor-run-${HEADING}"]`).click();
await page
.locator(`[data-testid="pdf-editor-run-${BODY}"]`)
.click({ modifiers: ["Shift"] });
await page.waitForTimeout(400);
expect(
(await selectedRunIds(page)).length,
"the selection must span both runs",
).toBe(2);
expect(await page.getByTestId("pdf-editor-colour").inputValue()).toBe(
"#000000",
);
// Open the picker and pick out of the saturation square itself.
await page.getByTestId("pdf-editor-colour").click();
const overlay = page
.locator(".mantine-ColorInput-saturationOverlay")
.first();
await expect(overlay).toBeVisible({ timeout: 3_000 });
// The saturation area stacks three overlays; click the container itself.
const saturation = page.locator(".mantine-ColorInput-saturation").first();
const box = (await saturation.boundingBox())!;
await saturation.click({ position: { x: box.width - 4, y: 4 } });
await page.waitForTimeout(1500);
const headingFill = await modelFill(page, HEADING);
const bodyFill = await modelFill(page, BODY);
expect(
headingFill,
`a mixed selection must end up unified (heading ${headingFill}, body ${bodyFill})`,
).toBe(bodyFill);
expect(bodyFill, "the body must have been recoloured too").not.toBe(
"0,0,0",
);
});
test("Escape closes the colour dropdown", async ({ page }) => {
await openEditor(page, PARAGRAPH_PDF);
await selectRun(page, HEADING);
const overlay = page
.locator(".mantine-ColorInput-saturationOverlay")
.first();
await page.getByTestId("pdf-editor-colour").click();
await expect(
overlay,
"clicking the swatch should open the picker",
).toBeVisible({ timeout: 3_000 });
await page.getByTestId("pdf-editor-colour").press("Escape");
await expect(
overlay,
"Escape should dismiss the colour dropdown",
).toBeHidden({ timeout: 3_000 });
});
});
@@ -0,0 +1,161 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import path from "path";
import { uploadFiles } from "@app/tests/helpers/ui-helpers";
// The editor could reach an empty state it could not leave. Auto-open stood
// down on its own memory of having opened a file, but that memory lives in a
// panel ref while the document lives in a module-singleton store that drops it
// when the canvas unmounts. Once the two disagreed, every guard said "already
// opened" while the screen said "no document", and re-selecting the file did
// nothing.
//
// Both tests use a real workbench file, because that is the only kind
// auto-open has a candidate for: a file dropped straight onto the editor is
// deliberately not one.
const SAMPLE = path.join(
import.meta.dirname,
"../../../../public/samples/Sample.pdf",
);
interface EditorWindow {
__editor_store?: {
state: {
hasDocument: boolean;
loading: boolean;
error: string | null;
};
clearDocument: () => void;
};
}
async function editorState(page: Page) {
return page.evaluate(() => {
const s = (window as unknown as EditorWindow).__editor_store;
if (!s) return null;
const { hasDocument, loading, error } = s.state;
return { hasDocument, loading, error };
});
}
/** Everything that decides whether auto-open can fire, for a failure message. */
async function loadContext(page: Page) {
return page.evaluate(() => {
const s = (window as unknown as EditorWindow).__editor_store;
const idb = (
window as unknown as { __file_debug?: Record<string, unknown> }
).__file_debug;
return {
hasDocument: s?.state.hasDocument ?? null,
loading: s?.state.loading ?? null,
error: s?.state.error ?? null,
stage: !!document.querySelector('[data-testid="pdf-editor-stage"]'),
pages: document.querySelectorAll('[data-testid^="pdf-editor-page-"]')
.length,
fileItems: document.querySelectorAll(".file-sidebar-file-item").length,
idb: idb ?? null,
};
});
}
async function expectDocumentOpen(page: Page, when: string) {
await expect
.poll(async () => (await editorState(page))?.hasDocument ?? false, {
timeout: 25_000,
intervals: [300, 500, 800, 1200],
message: `the editor never held a document ${when}`,
})
.toBe(true)
.catch(async (err: unknown) => {
// eslint-disable-next-line no-console
console.log(
`EMPTYSTATE-FAIL ${when}: ${JSON.stringify(await loadContext(page))}`,
);
throw err;
});
}
/** Put the sample in the workbench, then open the text editor on it. */
async function openEditorOnWorkbenchFile(page: Page): Promise<void> {
await page.goto("/", { waitUntil: "domcontentloaded" });
await uploadFiles(page, SAMPLE);
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await expectDocumentOpen(page, "after opening the tool on a workbench file");
// hasDocument flips true before the pages finish loading, and a clear that
// lands mid-load disposes the document being read ("failed to load page 2"),
// which is the invalid injection the rounds below already guard against.
// Settle the same way they do before handing the editor to the test body.
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(800);
}
test.describe("PDF text editor - it never gets stuck with no document", () => {
test("the canvas dropping its document does not strand the editor", async ({
page,
}) => {
test.setTimeout(180_000);
await openEditorOnWorkbenchFile(page);
// clearDocument is exactly what the canvas unmounting does to the shared
// store, so this is the disagreement itself rather than an imitation of it.
for (let round = 1; round <= 3; round += 1) {
await page.evaluate(() => {
(window as unknown as EditorWindow).__editor_store?.clearDocument();
});
expect(
(await editorState(page))?.hasDocument,
`round ${round}: the store did not actually clear`,
).toBe(false);
await expectDocumentOpen(page, `after clear round ${round}`);
// Let the recovery finish before dropping the document again. The real
// trigger is a canvas unmount, which cannot arrive twice inside a load;
// clearing mid-load disposes the document being read and fails the page.
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(800);
}
});
test("deselecting and reselecting the file leaves a document open", async ({
page,
}) => {
test.setTimeout(180_000);
await openEditorOnWorkbenchFile(page);
// Opening Active Files swaps the canvas away on purpose, and the editor
// must not pin it back (see pdf-text-editor-workbench-files). So the
// round is: go to the list, toggle the selection, come back - and the
// editor has to have a document again when the user returns to it.
for (let round = 1; round <= 3; round += 1) {
const filesButton = page.getByTestId("files-button");
await filesButton.click();
const item = page.locator(".file-sidebar-file-item").first();
await expect(item).toBeVisible({ timeout: 10_000 });
await item.click();
await page.waitForTimeout(500);
await item.click();
await page.waitForTimeout(500);
// eslint-disable-next-line no-console
console.log(
`EMPTYSTATE round ${round} in list: ${JSON.stringify(await editorState(page))}`,
);
await filesButton.click();
await page.waitForTimeout(1500);
// eslint-disable-next-line no-console
console.log(
`EMPTYSTATE round ${round} back: ${JSON.stringify(await editorState(page))}`,
);
await expectDocumentOpen(
page,
`after returning from the list, round ${round}`,
);
}
});
});
@@ -0,0 +1,348 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import path from "path";
// Regressions for two selection bugs on the stage:
// (A) a corner resize of an image dropped the image out of the selection,
// because the resize handle's pointerdown bubbled to the stage's
// "click empty space clears" handler and nothing re-selected after.
// (B) a Ctrl+Shift marquee wiped the current selection at pointerdown even
// when the rectangle went on to catch nothing.
//
// Both are measured off the DOM/geometry the user actually sees (outline
// style, overlay box) as well as the store, so a fix that only patches the
// store would still fail here.
const SAMPLE = path.join(import.meta.dirname, "../test-fixtures/sample.pdf");
// `[data-testid^="pdf-editor-image-"]` also matches the hidden file input; image
// overlays are always `pdf-editor-image-p<page>-<obj>`.
const IMG_SEL = '[data-testid^="pdf-editor-image-p"]';
const RUN_SEL = '[data-testid^="pdf-editor-run-p0-"]';
interface EditorWin {
__editor_store: {
selection: {
value: { runIds: string[]; imageIds: string[] };
selectMany: (ids: string[], additive?: boolean) => void;
selectOne: (id: string) => void;
};
};
}
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
async function openSample(page: Page): Promise<void> {
await page.route("**/encode-charcodes", (route) => route.abort());
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(SAMPLE);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 60_000,
});
await page.waitForTimeout(1500);
}
function selectedImageIds(page: Page): Promise<string[]> {
return page.evaluate(
() =>
(window as unknown as EditorWin).__editor_store.selection.value.imageIds,
);
}
function selectedRunIds(page: Page): Promise<string[]> {
return page.evaluate(
() =>
(window as unknown as EditorWin).__editor_store.selection.value.runIds,
);
}
/** Drag with intermediate steps so react-rnd / the marquee track the gesture. */
async function dragMouse(
page: Page,
from: { x: number; y: number },
to: { x: number; y: number },
): Promise<void> {
await page.mouse.move(from.x, from.y);
await page.waitForTimeout(120);
await page.mouse.down();
await page.mouse.move(to.x, to.y, { steps: 12 });
await page.waitForTimeout(120);
await page.mouse.up();
}
/** Ctrl+Shift+drag with the real mouse - the app's marquee gesture. */
async function marquee(
page: Page,
from: { x: number; y: number },
to: { x: number; y: number },
): Promise<void> {
await page.keyboard.down("Control");
await page.keyboard.down("Shift");
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.mouse.move((from.x + to.x) / 2, (from.y + to.y) / 2, { steps: 4 });
await page.mouse.move(to.x, to.y, { steps: 4 });
await page.mouse.up();
await page.keyboard.up("Shift");
await page.keyboard.up("Control");
await page.waitForTimeout(250);
}
async function pageBox(page: Page): Promise<Rect> {
const b = await page.getByTestId("pdf-editor-page-0").boundingBox();
if (!b) throw new Error("page 0 has no bounding box");
return b;
}
/**
* Two points in the bare gutter LEFT of the page but still inside `pdf-editor-pages`
* (the stage's clear-on-press target, and the only region the marquee arms in).
* Outside `pdf-editor-pages` neither handler runs and any assertion would pass blind.
*/
async function gutter(page: Page): Promise<{
from: { x: number; y: number };
to: { x: number; y: number };
}> {
const pages = await page.getByTestId("pdf-editor-pages").boundingBox();
const p0 = await pageBox(page);
if (!pages) throw new Error("pdf-editor-pages has no bounding box");
const slack = p0.x - pages.x;
expect(slack, "fixture needs bare stage left of the page").toBeGreaterThan(
60,
);
return {
from: { x: pages.x + slack * 0.25, y: p0.y + 120 },
to: { x: pages.x + slack * 0.75, y: p0.y + 320 },
};
}
test.describe("PDF text editor - selection survives resize, marquee never wipes blind", () => {
test.setTimeout(120_000);
test.beforeEach(async ({ page }) => {
await page.route("**/encode-charcodes", (route) => route.abort());
});
// (A) Before the fix: imageIds went ["p0-i46"] -> [] the instant the corner
// handle was pressed, and the overlay outline fell back from solid to none.
test("corner-resizing an image keeps the image selected", async ({
page,
}) => {
await openSample(page);
const img = page.locator(IMG_SEL).first();
await expect(img).toBeVisible({ timeout: 30_000 });
await img.click();
await expect(img).toHaveCSS("outline-style", "solid");
const before = await selectedImageIds(page);
expect(before.length, "clicking the image selects it").toBe(1);
const box = await img.boundingBox();
if (!box) throw new Error("image overlay has no bounding box");
await dragMouse(
page,
{ x: box.x + box.width - 2, y: box.y + box.height - 2 },
{ x: box.x + box.width + 90, y: box.y + box.height + 45 },
);
await page.waitForTimeout(600);
// The resize really happened (otherwise "still selected" proves nothing).
const grown = await img.boundingBox();
if (!grown) throw new Error("image overlay vanished after the resize");
expect(
grown.width - box.width,
"the corner drag actually resized the overlay",
).toBeGreaterThan(60);
const after = await selectedImageIds(page);
expect(
after,
`the resized image stays selected (was ${JSON.stringify(before)}, now ${JSON.stringify(after)})`,
).toEqual(before);
await expect(
img,
"the outline stays solid, not the dashed hover state",
).toHaveCSS("outline-style", "solid");
});
// Guard for (A): the stage must still clear when the user clicks bare space.
test("clicking empty stage space still clears an image selection", async ({
page,
}) => {
await openSample(page);
const img = page.locator(IMG_SEL).first();
await expect(img).toBeVisible({ timeout: 30_000 });
await img.click();
expect((await selectedImageIds(page)).length).toBe(1);
const g = await gutter(page);
await page.mouse.click(g.from.x, g.from.y);
await page.waitForTimeout(200);
expect(
await selectedImageIds(page),
"a plain click on empty space clears the selection",
).toEqual([]);
});
// (B) Before the fix: runIds went [id] -> [] because PageStage cleared at
// the marquee's pointerdown, and the empty marquee never restored anything.
test("a marquee that catches nothing leaves the existing selection alone", async ({
page,
}) => {
await openSample(page);
const run = page.locator(RUN_SEL).first();
await expect(run).toBeVisible({ timeout: 30_000 });
await run.click();
const before = await selectedRunIds(page);
expect(before.length, "clicking a run selects it").toBe(1);
const g = await gutter(page);
await marquee(page, g.from, g.to);
const after = await selectedRunIds(page);
expect(
after,
`an empty marquee must not wipe the selection (was ${JSON.stringify(before)}, now ${JSON.stringify(after)})`,
).toEqual(before);
});
// (B) The other half of the contract: a marquee that DOES catch runs still
// replaces, so the fix above cannot have turned every marquee additive.
test("a plain marquee replaces the selection with what it caught", async ({
page,
}) => {
await openSample(page);
await expect(page.locator(RUN_SEL).first()).toBeVisible({
timeout: 30_000,
});
// Tight box around the first run, plus the ids that box actually covers.
const target = await page.evaluate(() => {
const runs = Array.from(
document.querySelectorAll<HTMLElement>(
'[data-testid^="pdf-editor-run-p0-"]',
),
);
const id = (el: HTMLElement) =>
el.dataset.testid!.replace(/^pdf-editor-run-/, "");
const b = runs[0].getBoundingClientRect();
const rect = {
left: b.left - 4,
top: b.top - 4,
right: b.right + 4,
bottom: b.bottom + 4,
};
const caught = runs
.filter((el) => {
const r = el.getBoundingClientRect();
return (
r.right >= rect.left &&
r.left <= rect.right &&
r.bottom >= rect.top &&
r.top <= rect.bottom
);
})
.map(id);
return {
rect,
caught,
first: id(runs[0]),
last: id(runs[runs.length - 1]),
};
});
expect(
target.caught,
"fixture check: this box must cover exactly the first run",
).toEqual([target.first]);
expect(target.last).not.toBe(target.first);
await page.evaluate(
(id: string) =>
(window as unknown as EditorWin).__editor_store.selection.selectOne(id),
target.last,
);
expect(await selectedRunIds(page)).toEqual([target.last]);
await marquee(
page,
{ x: target.rect.left, y: target.rect.top },
{ x: target.rect.right, y: target.rect.bottom },
);
const after = await selectedRunIds(page);
expect(
after,
`a plain marquee replaces (expected [${target.first}], got ${JSON.stringify(after)})`,
).toEqual([target.first]);
});
// Guard for (B): a plain (non-modifier) press on empty space still clears.
test("a plain click on empty space still clears a run selection", async ({
page,
}) => {
await openSample(page);
const run = page.locator(RUN_SEL).first();
await expect(run).toBeVisible({ timeout: 30_000 });
await run.click();
expect((await selectedRunIds(page)).length).toBe(1);
const g = await gutter(page);
await page.mouse.click(g.from.x, g.from.y);
await page.waitForTimeout(200);
expect(
await selectedRunIds(page),
"a plain click on empty space clears the run selection",
).toEqual([]);
});
// (B) The store-level seam an additive rectangle-select needs: replace by
// default, union (order-preserving, deduped) when asked to extend.
test("selectMany extends the selection when called additively", async ({
page,
}) => {
await openSample(page);
await expect(page.locator(RUN_SEL).first()).toBeVisible({
timeout: 30_000,
});
const ids = await page
.locator(RUN_SEL)
.evaluateAll((els) =>
els
.slice(0, 3)
.map((el) =>
(el as HTMLElement).dataset.testid!.replace(/^pdf-editor-run-/, ""),
),
);
expect(ids.length, "fixture needs at least 3 runs").toBe(3);
const result = await page.evaluate((rids: string[]) => {
const sel = (window as unknown as EditorWin).__editor_store.selection;
sel.selectMany([rids[0]]);
const first = [...sel.value.runIds];
sel.selectMany([rids[1], rids[2]], true);
const extended = [...sel.value.runIds];
sel.selectMany([rids[2]], true);
const deduped = [...sel.value.runIds];
sel.selectMany([rids[0]]);
const replaced = [...sel.value.runIds];
return { first, extended, deduped, replaced };
}, ids);
expect(result.first).toEqual([ids[0]]);
expect(result.extended, "additive selectMany unions").toEqual(ids);
expect(result.deduped, "additive selectMany does not duplicate").toEqual(
ids,
);
expect(result.replaced, "the default stays replace").toEqual([ids[0]]);
});
});
@@ -0,0 +1,250 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import path from "path";
// The font-size box went disabled the moment a selection held two different
// sizes, so the one control that could make a mixed selection uniform was the
// one control you could not reach. The family picker has always handled the
// same case by showing "Mixed" and staying usable; the size box now matches.
//
// The assertions below are on the MODEL sizes after the change plus the ink
// the page actually rendered, not on the input's own value.
const FIX = (n: string): string =>
path.join(import.meta.dirname, "../test-fixtures", n);
interface RunInfo {
id: string;
size: number;
text: string;
rect: { x: number; y: number; width: number; height: number };
}
async function open(page: Page, file: string): Promise<void> {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(file);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 60_000,
});
await page.waitForFunction(
() =>
((
window as unknown as {
__editor_store?: { state: { pages: { runs: unknown[] }[] } };
}
).__editor_store?.state.pages[0]?.runs.length ?? 0) > 0,
undefined,
{ timeout: 60_000 },
);
await page.waitForTimeout(800);
}
async function runsOnPage0(page: Page): Promise<RunInfo[]> {
return page.evaluate(() => {
const store = (
window as unknown as {
__editor_store: {
state: {
pages: {
runs: { id: string; fontSize: number; text: string }[];
}[];
};
};
}
).__editor_store;
const out: RunInfo[] = [];
for (const r of store.state.pages[0]?.runs ?? []) {
const el = document.querySelector<HTMLElement>(
`[data-testid="pdf-editor-run-${r.id}"]`,
);
if (!el) continue;
const b = el.getBoundingClientRect();
if (b.width < 4 || b.height < 4) continue;
out.push({
id: r.id,
size: r.fontSize,
text: r.text,
rect: { x: b.x, y: b.y, width: b.width, height: b.height },
});
}
return out;
}) as Promise<RunInfo[]>;
}
interface Clip {
x: number;
y: number;
width: number;
height: number;
}
// Grab the band's pixels and stash them in the page, or - once a snapshot is
// already stashed - report the share of pixels that differ from it.
//
// A dark-pixel COUNT is the wrong instrument on this fixture: the band also
// holds page artwork, so 208k pixels were already dark and resizing the two
// runs moved the count by 1%. Glyphs that move and grow change a large share
// of the pixels whatever is behind them.
const BAND_FN = (c: Clip & { mode: "snap" | "diff" }): number => {
const canvas = document
.querySelector<HTMLElement>('[data-testid="pdf-editor-page-0"]')
?.querySelector("canvas");
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return -1;
const cb = canvas.getBoundingClientRect();
const sx = canvas.width / cb.width;
const sy = canvas.height / cb.height;
const x0 = Math.max(0, Math.floor((c.x - cb.left) * sx));
const y0 = Math.max(0, Math.floor((c.y - cb.top) * sy));
const x1 = Math.min(canvas.width, Math.ceil((c.x + c.width - cb.left) * sx));
const y1 = Math.min(canvas.height, Math.ceil((c.y + c.height - cb.top) * sy));
if (x1 <= x0 || y1 <= y0) return -1;
const d = ctx.getImageData(x0, y0, x1 - x0, y1 - y0).data;
const w = window as unknown as { __band?: Uint8ClampedArray };
if (c.mode === "snap") {
w.__band = new Uint8ClampedArray(d);
return d.length / 4;
}
const prev = w.__band;
if (!prev || prev.length !== d.length) return -1;
let changed = 0;
for (let i = 0; i < d.length; i += 4) {
// 24 tolerates antialiasing jitter without hiding a real glyph change.
if (
Math.abs(d[i] - prev[i]) > 24 ||
Math.abs(d[i + 1] - prev[i + 1]) > 24 ||
Math.abs(d[i + 2] - prev[i + 2]) > 24
) {
changed++;
}
}
return changed / (d.length / 4);
};
/** Union of two run boxes, padded so grown glyphs stay inside the window. */
function bandAround(a: RunInfo, b: RunInfo, pad = 40): Clip {
const x = Math.min(a.rect.x, b.rect.x) - pad;
const y = Math.min(a.rect.y, b.rect.y) - pad;
const right =
Math.max(a.rect.x + a.rect.width, b.rect.x + b.rect.width) + pad;
const bottom =
Math.max(a.rect.y + a.rect.height, b.rect.y + b.rect.height) + pad;
return { x, y, width: right - x, height: bottom - y };
}
const clickRun = async (page: Page, r: RunInfo, shift: boolean) => {
if (shift) await page.keyboard.down("Shift");
await page.mouse.click(
r.rect.x + r.rect.width / 2,
r.rect.y + r.rect.height / 2,
);
if (shift) await page.keyboard.up("Shift");
await page.mouse.move(20, 20);
await page.waitForTimeout(200);
};
test.describe("PDF text editor - font size on a mixed-size selection", () => {
test("the size box stays usable and applies to every selected run", async ({
page,
}) => {
test.setTimeout(180_000);
await open(page, FIX("stirling-marketing.pdf"));
const runs = await runsOnPage0(page);
const first = runs[0];
expect(first, "fixture rendered no runs").toBeTruthy();
const other = runs.find((r) => Math.abs(r.size - first.size) > 1);
expect(
other,
`fixture has no two runs of different size (sizes seen: ${[
...new Set(runs.map((r) => r.size.toFixed(1))),
].join(", ")})`,
).toBeTruthy();
// eslint-disable-next-line no-console
console.log(
`MIXEDSIZE a=${first.size.toFixed(2)} "${first.text.slice(0, 24)}" ` +
`b=${other!.size.toFixed(2)} "${other!.text.slice(0, 24)}"`,
);
const band = bandAround(first, other!);
const sampled = await page.evaluate(BAND_FN, {
...band,
mode: "snap" as const,
});
expect(sampled, "the band sampled no canvas pixels").toBeGreaterThan(200);
// Control: how much this band moves when nothing is done to it. Without
// this the "did it re-render" threshold below would be a guessed number.
await page.waitForTimeout(900);
const idle = await page.evaluate(BAND_FN, {
...band,
mode: "diff" as const,
});
await clickRun(page, first, false);
await clickRun(page, other!, true);
const ids = await page.evaluate(
() =>
(
window as unknown as {
__editor_store: { selection: { state: { runIds: string[] } } };
}
).__editor_store.selection.state.runIds,
);
expect(ids.length, "shift-click did not build a two-run selection").toBe(2);
const box = page.getByTestId("pdf-editor-font-size");
await expect(
box,
"the size box is disabled for a mixed-size selection",
).toBeEnabled();
// Blank, not one of the two sizes presented as if it were both.
await expect(
box,
"the box shows one run's size as if it were all",
).toHaveValue("");
// Re-snap once the runs are selected, so the diff below measures the
// resize and not the selection highlight.
await page.evaluate(BAND_FN, { ...band, mode: "snap" as const });
await box.fill("30");
await page.waitForTimeout(900);
const after = await runsOnPage0(page);
const sizes = [first.id, other!.id].map(
(id) => after.find((r) => r.id === id)?.size ?? -1,
);
// eslint-disable-next-line no-console
console.log(`MIXEDSIZE applied=${JSON.stringify(sizes)}`);
for (const s of sizes) {
expect(s, `a selected run kept its old size (got ${s})`).toBeCloseTo(
30,
0,
);
}
// The page really re-rendered at the new size, not just the model.
const changed = await page.evaluate(BAND_FN, {
...band,
mode: "diff" as const,
});
// eslint-disable-next-line no-console
console.log(
`MIXEDSIZE bandChanged=${(changed * 100).toFixed(2)}% idle=${(idle * 100).toFixed(2)}%`,
);
expect(
changed,
`the resize moved ${(changed * 100).toFixed(2)}% of the band's pixels, barely above the ${(idle * 100).toFixed(2)}% an untouched page moves`,
).toBeGreaterThan(Math.max(0.01, idle * 8));
// With both runs at 30 the selection is uniform again, so the box shows it.
await expect(box).toHaveValue("30");
});
});
@@ -0,0 +1,254 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import path from "path";
/**
* Regression cover for the PDF text editor's near-viewport bitmap prefetch.
*
* PageView keeps a page's PDFium bitmap only while the page is "near" the
* viewport, and frees the canvas otherwise (a 4x-zoom A4 canvas is ~32MB).
* The observer that decides "near" uses rootMargin: 800px, but the pages live
* inside a Mantine ScrollArea. IntersectionObserver clips the target against
* every scrolling ancestor BEFORE root+rootMargin is applied, so with the
* default (document) root a page one pixel outside the ScrollArea is already
* non-intersecting and the margin can never bring it back - the prefetch was
* dead code and pages popped in as blank placeholders on scroll.
*
* These tests measure it from geometry + canvas backing stores only:
* - a page whose top edge is inside the 800px margin must be rendered
* - a page well outside it must still be freed (memory behaviour)
*/
const MANY_PAGES_PDF = path.join(
import.meta.dirname,
"../test-fixtures/many-pages-sample.pdf",
);
type Page = import("@playwright/test").Page;
interface PageGeom {
index: number;
/** Canvas backing-store width; 0 means the bitmap was freed. */
canvasW: number;
placeholder: boolean;
/** px from the scroll viewport's bottom edge down to the page's top edge. */
gapBelow: number;
/** px from the page's bottom edge up to the scroll viewport's top edge. */
gapAbove: number;
}
/** Geometry + bitmap state of every mounted page, relative to the ScrollArea. */
function scanPages(p: Page): Promise<{ vpH: number; pages: PageGeom[] }> {
return p.evaluate(() => {
const vp = document.querySelector<HTMLElement>(
'[data-testid="pdf-editor-stage"] .mantine-ScrollArea-viewport',
);
if (!vp) throw new Error("ScrollArea viewport not found");
const vr = vp.getBoundingClientRect();
const pages = Array.from(
document.querySelectorAll<HTMLElement>(
"[data-testid^='pdf-editor-page-']",
),
)
.filter((el) => /^pdf-editor-page-\d+$/.test(el.dataset.testid ?? ""))
.map((el) => {
const idx = Number(
(el.dataset.testid ?? "").replace("pdf-editor-page-", ""),
);
const r = el.getBoundingClientRect();
const c = el.querySelector("canvas");
return {
index: idx,
canvasW: c ? c.width : -1,
placeholder: !!el.querySelector(
`[data-testid="pdf-editor-page-${idx}-placeholder"]`,
),
gapBelow: Math.round(r.top - vr.bottom),
gapAbove: Math.round(vr.top - r.bottom),
};
});
return { vpH: Math.round(vr.height), pages };
});
}
function fmt(scan: { vpH: number; pages: PageGeom[] }) {
return scan.pages
.map(
(g) =>
`page ${g.index}: canvas.width=${g.canvasW} placeholder=${g.placeholder} gapBelow=${g.gapBelow} gapAbove=${g.gapAbove}`,
)
.join("\n");
}
/** Scroll so page `idx`'s top edge sits exactly `gap` px below the fold. */
async function parkGapBelow(p: Page, idx: number, gap: number) {
await p.evaluate(
({ i, g }) => {
const vp = document.querySelector<HTMLElement>(
'[data-testid="pdf-editor-stage"] .mantine-ScrollArea-viewport',
);
const el = document.querySelector<HTMLElement>(
`[data-testid="pdf-editor-page-${i}"]`,
);
if (!vp || !el) throw new Error("stage or page missing");
const current =
el.getBoundingClientRect().top - vp.getBoundingClientRect().bottom;
vp.scrollTop += current - g;
},
{ i: idx, g: gap },
);
}
async function openEditor(p: Page, file: string) {
await p.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(p.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await p.locator('[data-testid="pdf-editor-file-input"]').setInputFiles(file);
await expect(p.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 60_000,
});
await p.waitForTimeout(2500);
}
/**
* Poll until the page geometry settles and every pending render has landed.
* Renders are async and can take >1s on a loaded machine, so hold out for
* three identical samples in a row rather than two.
*/
async function settledScan(p: Page) {
let last = JSON.stringify(await scanPages(p));
let stable = 0;
for (let i = 0; i < 60; i++) {
await p.waitForTimeout(500);
const next = await scanPages(p);
const key = JSON.stringify(next);
stable = key === last ? stable + 1 : 0;
last = key;
if (stable >= 3) return next;
}
return JSON.parse(last) as { vpH: number; pages: PageGeom[] };
}
/** The prefetch margin PageView asks for. */
const MARGIN = 800;
/** Comfortably inside / outside the margin, to keep the test off the edge. */
const INSIDE = 700;
const OUTSIDE = 1000;
test.describe("near-viewport prefetch", () => {
test("a page inside the 800px prefetch margin is rendered, not a placeholder", async ({
page,
}) => {
await openEditor(page, MANY_PAGES_PDF);
const scan = await settledScan(page);
console.log(`[at load] viewport height ${scan.vpH}\n${fmt(scan)}`);
const inside = scan.pages.filter(
(g) => g.gapBelow > 0 && g.gapBelow <= INSIDE,
);
expect(
inside.length,
`fixture must place at least one page 0..${INSIDE}px below the fold; got\n${fmt(scan)}`,
).toBeGreaterThan(0);
for (const g of inside) {
expect(
g.canvasW,
`page ${g.index} sits ${g.gapBelow}px below the fold (inside the ${MARGIN}px prefetch margin) so its bitmap must already be rendered\n${fmt(scan)}`,
).toBeGreaterThan(0);
expect(
g.placeholder,
`page ${g.index} sits ${g.gapBelow}px below the fold and must not show the placeholder\n${fmt(scan)}`,
).toBe(false);
}
});
test("pages far outside the margin are still freed", async ({ page }) => {
await openEditor(page, MANY_PAGES_PDF);
const scan = await settledScan(page);
console.log(`[memory guard] viewport height ${scan.vpH}\n${fmt(scan)}`);
const far = scan.pages.filter((g) => g.gapBelow > OUTSIDE);
expect(
far.length,
`fixture must place at least one page >${OUTSIDE}px below the fold; got\n${fmt(scan)}`,
).toBeGreaterThan(0);
for (const g of far) {
expect(
g.canvasW,
`page ${g.index} sits ${g.gapBelow}px below the fold (well outside the ${MARGIN}px margin) so its bitmap must stay freed\n${fmt(scan)}`,
).toBe(0);
}
});
// Pages in this fixture are 1236px apart, so the at-load case only ever
// exercises a 267px gap. Park one page at a chosen distance instead, which
// pins the margin: dead at 900px below the fold, live at 700px.
test("the prefetch margin is ~800px, measured by parking one page", async ({
page,
}) => {
await openEditor(page, MANY_PAGES_PDF);
await parkGapBelow(page, 3, OUTSIDE - 100);
const outside = await settledScan(page);
const far = outside.pages.find((g) => g.index === 3)!;
console.log(`[page 3 parked ~900px below]\n${fmt(outside)}`);
expect(far.gapBelow).toBeGreaterThan(MARGIN);
expect(
far.canvasW,
`page 3 parked ${far.gapBelow}px below the fold is outside the ${MARGIN}px margin and must stay freed\n${fmt(outside)}`,
).toBe(0);
await parkGapBelow(page, 3, INSIDE);
const inside = await settledScan(page);
const near = inside.pages.find((g) => g.index === 3)!;
console.log(`[page 3 parked ~700px below]\n${fmt(inside)}`);
expect(near.gapBelow).toBeLessThan(MARGIN);
expect(near.gapBelow).toBeGreaterThan(0);
expect(
near.canvasW,
`page 3 parked ${near.gapBelow}px below the fold is inside the ${MARGIN}px margin and must be rendered ahead of the scroll\n${fmt(inside)}`,
).toBeGreaterThan(0);
expect(near.placeholder).toBe(false);
});
test("prefetch also covers pages just above the viewport", async ({
page,
}) => {
await openEditor(page, MANY_PAGES_PDF);
await page.evaluate(() => {
document
.querySelector<HTMLElement>('[data-testid="pdf-editor-page-4"]')
?.scrollIntoView({ block: "center" });
});
const scan = await settledScan(page);
console.log(`[page 4 centred] viewport height ${scan.vpH}\n${fmt(scan)}`);
const above = scan.pages.filter(
(g) => g.gapAbove > 0 && g.gapAbove <= INSIDE,
);
expect(
above.length,
`expected a page 0..${INSIDE}px above the viewport; got\n${fmt(scan)}`,
).toBeGreaterThan(0);
for (const g of above) {
expect(
g.canvasW,
`page ${g.index} sits ${g.gapAbove}px above the viewport (inside the ${MARGIN}px prefetch margin) so its bitmap must still be live\n${fmt(scan)}`,
).toBeGreaterThan(0);
}
const far = scan.pages.filter(
(g) => g.gapAbove > OUTSIDE || g.gapBelow > OUTSIDE,
);
expect(far.length).toBeGreaterThan(0);
for (const g of far) {
expect(
g.canvasW,
`page ${g.index} is far from the viewport (gapAbove=${g.gapAbove} gapBelow=${g.gapBelow}) so its bitmap must stay freed\n${fmt(scan)}`,
).toBe(0);
}
});
});
@@ -0,0 +1,120 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import { createHash } from "crypto";
import { readFileSync } from "fs";
import path from "path";
import { downloadBytes, saveAndDownload } from "@app/tests/stubbed/saveHelpers";
// Opening a file and pressing save is not an edit, so it must not rewrite the
// file. Routed through the full PDFium serialiser it changed the bytes of 8 of
// this suite's 10 fixtures: paragraph-sample 1390 -> 1884 (+35.5%),
// justified-sample +33.4%. paragraph-sample carries the largest signal.
//
// Gating on `dirty` instead is a trap - it clears on save, so a second save
// after a real edit hands back the pre-edit bytes and silently reverts the
// user's work. The second test is that regression.
const FIX = (n: string): string =>
path.join(import.meta.dirname, "../test-fixtures", n);
// +35.5% through the unfixed serialiser - the loudest case in the fixture set.
const SAMPLE = FIX("paragraph-sample.pdf");
const sha = (b: Buffer): string => createHash("sha256").update(b).digest("hex");
async function open(page: Page, file: string): Promise<void> {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(file);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 60_000,
});
await page.waitForFunction(
() =>
((
window as unknown as {
__editor_store?: { state: { pages: { runs: unknown[] }[] } };
}
).__editor_store?.state.pages[0]?.runs.length ?? 0) > 0,
undefined,
{ timeout: 60_000 },
);
await page.waitForTimeout(500);
}
/** Type `text` at the end of the first run on page 0 and commit it. */
async function editFirstRun(page: Page, text: string): Promise<void> {
const run = page.locator('[data-testid^="pdf-editor-run-p0-"]').first();
await run.click();
await page.keyboard.press("End");
await page.keyboard.type(text, { delay: 25 });
await page
.locator('[data-testid="pdf-editor-page-0"]')
.click({ position: { x: 5, y: 5 } });
await page.waitForTimeout(600);
}
test.describe("PDF text editor - an unedited save does not rewrite the file", () => {
test("saving without editing returns the opened bytes untouched", async ({
page,
}) => {
test.setTimeout(120_000);
await open(page, SAMPLE);
const source = readFileSync(SAMPLE);
const saved = await downloadBytes(await saveAndDownload(page, false));
// eslint-disable-next-line no-console
console.log(
`PRISTINE source=${source.length} saved=${saved.length} ` +
`grow=${(((saved.length - source.length) / source.length) * 100).toFixed(1)}%`,
);
expect(
saved.length,
`an unedited save resized the file from ${source.length} to ${saved.length} bytes`,
).toBe(source.length);
expect(sha(saved), "an unedited save changed the file's bytes").toBe(
sha(source),
);
});
test("a second save after an edit still carries the edit", async ({
page,
}) => {
test.setTimeout(180_000);
await open(page, SAMPLE);
const source = readFileSync(SAMPLE);
const marker = "ZQXMARK";
await editFirstRun(page, marker);
const first = await downloadBytes(await saveAndDownload(page, false));
// No further edit between the two saves: this is exactly the state in
// which a dirty-flag-based shortcut would fall back to the opened bytes.
const second = await downloadBytes(await saveAndDownload(page, false));
// eslint-disable-next-line no-console
console.log(
`POSTEDIT source=${source.length} first=${first.length} second=${second.length}`,
);
expect(
sha(first),
"the first save after an edit was byte-identical to the source",
).not.toBe(sha(source));
expect(
sha(second),
`the second save reverted to the ${source.length}-byte source file`,
).not.toBe(sha(source));
// Both saves must describe the same edited document.
expect(
Math.abs(second.length - first.length),
"the second save's size drifted far from the first's",
).toBeLessThan(first.length * 0.05);
});
});
@@ -0,0 +1,183 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import path from "path";
// A rotated run's editable box was axis-aligned while its glyphs were not, so
// the box covered only part of the text it was supposed to be. Everything that
// depends on that box - clicking into the run, the hover ring, marquee hit
// testing, the caret - was therefore wrong for any rotated text.
//
// This measures the box against the run's OWN ink on the page bitmap: how much
// of the ink falls inside the box, and how much of the box is empty page.
const ROTATED = path.join(
import.meta.dirname,
"../test-fixtures/rotated-text-sample.pdf",
);
async function open(page: Page, file: string) {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(file);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 60_000,
});
await page.waitForTimeout(2000);
}
/**
* Ink coverage for one run's box.
*
* Scans the page canvas over the union of the box and the run's model bounds,
* and reports how many dark pixels fall inside the box versus outside it.
*/
function coverage(page: Page, testId: string) {
return page.evaluate((id: string) => {
const el = document.querySelector<HTMLElement>(`[data-testid="${id}"]`);
if (!el) return null;
const canvas = el
.closest("[data-testid^='pdf-editor-page-']")
?.querySelector("canvas") as HTMLCanvasElement | null;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return null;
const cb = canvas.getBoundingClientRect();
const sx = canvas.width / cb.width;
const sy = canvas.height / cb.height;
const b = el.getBoundingClientRect();
// Box in canvas pixels.
const bx0 = (b.left - cb.left) * sx;
const bx1 = (b.right - cb.left) * sx;
const by0 = (b.top - cb.top) * sy;
const by1 = (b.bottom - cb.top) * sy;
const d = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
let inside = 0;
let outside = 0;
let minX = 1e9,
maxX = -1e9,
minY = 1e9,
maxY = -1e9;
for (let y = 0; y < canvas.height; y++) {
for (let x = 0; x < canvas.width; x++) {
const i = (y * canvas.width + x) * 4;
if (d[i] >= 160 || d[i + 1] >= 160) continue;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
if (x >= bx0 && x <= bx1 && y >= by0 && y <= by1) inside++;
else outside++;
}
}
return {
inside,
outside,
total: inside + outside,
inkBox: [minX, minY, maxX, maxY],
box: [
+bx0.toFixed(1),
+by0.toFixed(1),
+(bx1 - bx0).toFixed(1),
+(by1 - by0).toFixed(1),
],
canvas: [canvas.width, canvas.height],
};
}, testId);
}
test.describe("PDF text editor - a rotated run's box follows its glyphs", () => {
test("the box contains the rotated text's ink", async ({ page }) => {
test.setTimeout(120_000);
await open(page, ROTATED);
const runs = page.locator('[data-testid^="pdf-editor-run-p0-"]');
const n = await runs.count();
expect(n, "fixture should hold at least one run").toBeGreaterThan(0);
// The rotated run in this fixture is the one whose model matrix is rotated.
const rotatedId = await page.evaluate(() => {
const s = (
window as unknown as {
__editor_store: {
state: {
pages: {
runs: {
id: string;
text: string;
matrix: { a: number; b: number };
}[];
}[];
};
};
}
).__editor_store;
for (const p of s.state.pages) {
for (const r of p.runs) {
const scale = Math.hypot(r.matrix.a, r.matrix.b);
if (scale && Math.abs(r.matrix.b / scale) > 0.05) return r.id;
}
}
return "";
});
if (!rotatedId) {
test.skip(true, "fixture has no rotated run");
return;
}
const cov = await coverage(page, `pdf-editor-run-${rotatedId}`);
expect(cov, "no canvas reading").not.toBeNull();
expect(cov!.total, "no ink found on the page at all").toBeGreaterThan(200);
const pct = (cov!.inside / cov!.total) * 100;
// eslint-disable-next-line no-console
console.log(
`ROTCOV inside=${cov!.inside} outside=${cov!.outside} pct=${pct.toFixed(1)} box=${JSON.stringify(cov!.box)} ink=${JSON.stringify(cov!.inkBox)}`,
);
// The box was axis-aligned over rotated glyphs and covered ~38% of them.
expect(
pct,
`the run's box covers only ${pct.toFixed(1)}% of the page's ink (box=${JSON.stringify(cov!.box)}, ink=${JSON.stringify(cov!.inkBox)})`,
).toBeGreaterThan(85);
});
test("the box stays on the page for a rotated page", async ({ page }) => {
test.setTimeout(120_000);
const ROT_PAGES = path.join(
import.meta.dirname,
"../test-fixtures/rotated-pages.pdf",
);
await open(page, ROT_PAGES);
const offenders = await page.evaluate(() => {
const out: string[] = [];
for (const el of document.querySelectorAll<HTMLElement>(
'[data-testid^="pdf-editor-run-p"]',
)) {
const canvas = el
.closest("[data-testid^='pdf-editor-page-']")
?.querySelector("canvas") as HTMLCanvasElement | null;
if (!canvas) continue;
const cb = canvas.getBoundingClientRect();
const b = el.getBoundingClientRect();
// A box may not hang off its own page by more than a hair.
const overRight = b.right - cb.right;
const overTop = cb.top - b.top;
if (overRight > 8 || overTop > 8) {
out.push(
`${el.getAttribute("data-testid")} overRight=${overRight.toFixed(1)} overTop=${overTop.toFixed(1)}`,
);
}
}
return out;
});
// eslint-disable-next-line no-console
console.log(`ROTPAGE offenders=${JSON.stringify(offenders)}`);
expect(
offenders,
`run boxes hang off their own page on a /Rotate page: ${offenders.join(", ")}`,
).toEqual([]);
});
});
@@ -0,0 +1,171 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import path from "path";
// Undoing a mid-word edit used to leave the edited glyphs on the page next to
// the restored ones. Typing "QQ" into "Heading in a bigger size" and pressing
// undo once gave a page reading "Heading in a Qbigger bigger sizesize" - the
// overlapping pairs are what makes undo look like it changed the font or
// mangled particular characters.
//
// Cause: a typed burst coalesces into ONE undo step spanning several commands.
// The first revert removed its own createdPtrs and re-emitted the run; the
// second then found ITS createdPtrs already gone, removed nothing, and
// re-emitted again, orphaning the first revert's objects on the page.
//
// The assertions are on the PDF's own extracted text and its object count, not
// on the model, because the model reverted correctly the whole time - it was
// the page that held two copies.
const PARAGRAPH = path.join(
import.meta.dirname,
"../test-fixtures/paragraph-sample.pdf",
);
interface DocWindow {
__editor_store: {
doc: {
module: {
FPDFPage_CountObjects: (p: number) => number;
FPDFText_LoadPage: (p: number) => number;
FPDFText_ClosePage: (tp: number) => void;
FPDFText_CountChars: (tp: number) => number;
FPDFText_GetUnicode: (tp: number, i: number) => number;
};
loadedPages: () => { pagePtr: number }[];
};
state: { pages: { runs: { fontId: string }[] }[] };
};
}
/** The page's own extracted text - a duplicated glyph run shows up as repeats. */
async function pageText(page: Page): Promise<string> {
return page.evaluate(() => {
const s = (window as unknown as DocWindow).__editor_store;
const pg = s.doc.loadedPages()[0];
if (!pg) return "";
const m = s.doc.module;
const tp = m.FPDFText_LoadPage(pg.pagePtr);
let out = "";
const n = m.FPDFText_CountChars(tp);
for (let i = 0; i < n; i++) {
const c = m.FPDFText_GetUnicode(tp, i);
if (c) out += String.fromCharCode(c);
}
m.FPDFText_ClosePage(tp);
return out;
});
}
async function objectCount(page: Page): Promise<number> {
return page.evaluate(() => {
const s = (window as unknown as DocWindow).__editor_store;
const pg = s.doc.loadedPages()[0];
return pg ? s.doc.module.FPDFPage_CountObjects(pg.pagePtr) : -1;
});
}
async function firstRunFont(page: Page): Promise<string> {
return page.evaluate(() => {
const s = (window as unknown as DocWindow).__editor_store;
return s.state.pages[0]?.runs[0]?.fontId ?? "<none>";
});
}
async function open(page: Page): Promise<void> {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(PARAGRAPH);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 60_000,
});
await page.waitForTimeout(2500);
}
async function undoAll(page: Page): Promise<void> {
for (let i = 0; i < 25; i += 1) {
const can = await page
.getByTestId("pdf-editor-undo")
.isEnabled()
.catch(() => false);
if (!can) break;
await page.getByTestId("pdf-editor-undo").click();
await page.waitForTimeout(250);
}
await page.waitForTimeout(1500);
}
const CASES = [
{ label: "typing mid-word", mode: "mid" as const },
{ label: "backspacing at the end", mode: "backspace" as const },
];
for (const c of CASES) {
test(`undo after ${c.label} leaves one copy of the text, not two`, async ({
page,
}) => {
test.setTimeout(180_000);
await open(page);
const textBefore = await pageText(page);
const objectsBefore = await objectCount(page);
const fontBefore = await firstRunFont(page);
expect(textBefore.length, "fixture produced no text").toBeGreaterThan(50);
const run = page.locator('[data-testid^="pdf-editor-run-p0-"]').first();
await run.click();
await page.waitForTimeout(400);
if (c.mode === "backspace") {
await page.keyboard.press("End");
await page.keyboard.press("Backspace");
await page.keyboard.press("Backspace");
} else {
// The click leaves the caret inside the word, so this splits the run.
await page.keyboard.type("QQ", { delay: 40 });
}
await page.waitForTimeout(1200);
await page
.locator('[data-testid="pdf-editor-page-0"]')
.click({ position: { x: 4, y: 4 } });
await page.waitForTimeout(1500);
const objectsEdited = await objectCount(page);
await undoAll(page);
const textAfter = await pageText(page);
const objectsAfter = await objectCount(page);
const fontAfter = await firstRunFont(page);
// eslint-disable-next-line no-console
console.log(
`UNDODUP ${c.label}: objects ${objectsBefore}->${objectsEdited}->${objectsAfter} ` +
`chars ${textBefore.length}->${textAfter.length} font ${fontBefore} -> ${fontAfter}`,
);
// Same characters, same count. Undo used to add 12 (a doubled "bigger" and
// "size") and a surviving "Q".
expect(
textAfter.length,
`undo changed the page's character count (was ${textBefore.length}, now ${textAfter.length}): "${textAfter.slice(-60)}"`,
).toBe(textBefore.length);
expect(
[...textAfter].sort().join(""),
"undo left different characters on the page",
).toBe([...textBefore].sort().join(""));
// Undo must not ADD objects. It used to go 5 -> 9 -> 14.
expect(
objectsAfter,
`undo grew the page from ${objectsEdited} objects to ${objectsAfter}`,
).toBeLessThanOrEqual(objectsEdited);
// The run keeps its embedded face; the revert used to re-emit as base-14.
expect(fontAfter, "undo swapped the run onto a fallback font").toBe(
fontBefore,
);
});
}
@@ -0,0 +1,142 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import path from "path";
// Undo must return the PAGE BITMAP to its pre-edit ink, not just the model text.
//
// A burst of typing coalesces into ONE undo step, so a single Ctrl+Z reverts
// several EditTextCommands in a row. Each one rebuilt the whole run from its own
// pre-edit snapshot and cleared paragraphLineSlots, so the next revert in the
// chain re-emitted everything again while the previous revert's objects stayed
// on the page. Typing four characters and undoing once took page 0 from 5 text
// objects to 15 to 48, and the original and edited glyphs were painted on top of
// each other while run.text read as correctly restored.
//
// Measured on paragraph-sample.pdf, dark pixels over the run's band:
// caret mid-token base 7550 -> edited 7821 -> undone 10298 (+2748) BEFORE
// -> undone 7552 (+2) AFTER
// caret end-token base 7550 -> edited 7825 -> undone 10296 (+2746) BEFORE
// -> undone 7552 (+2) AFTER
// Caret at the start of a token was always clean, which is why the model-level
// undo tests never caught this.
const FIX = path.join(
import.meta.dirname,
"../test-fixtures/paragraph-sample.pdf",
);
async function open(page: Page) {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(FIX);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 60_000,
});
await page.waitForTimeout(2000);
}
function inkOf(page: Page, testId: string, runId: string) {
return page.evaluate(
({ id, rid }: { id: string; rid: string }) => {
const el = document.querySelector<HTMLElement>(`[data-testid="${id}"]`);
if (!el) return null;
const canvas = el
.closest("[data-testid^='pdf-editor-page-']")
?.querySelector("canvas") as HTMLCanvasElement | null;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return null;
const cb = canvas.getBoundingClientRect();
const r = el.getBoundingClientRect();
const sx = canvas.width / cb.width;
const sy = canvas.height / cb.height;
const x = Math.max(0, Math.floor((r.left - cb.left) * sx) - 10);
const y = Math.max(0, Math.floor((r.top - cb.top) * sy) - 10);
const w = Math.min(canvas.width - x, Math.ceil(r.width * sx) + 60);
const h = Math.min(canvas.height - y, Math.ceil(r.height * sy) + 20);
const d = ctx.getImageData(x, y, w, h).data;
let dark = 0;
for (let i = 0; i < d.length; i += 4) {
if (d[i] < 160 && d[i + 1] < 160) dark++;
}
const s = (
window as unknown as {
__editor_store: {
state: { pages: { runs: { id: string; text: string }[] }[] };
};
}
).__editor_store;
let text: string | null = null;
for (const p of s.state.pages) {
const rr = p.runs.find((z) => z.id === rid);
if (rr) text = rr.text;
}
return { dark, text };
},
{ id: testId, rid: runId },
);
}
for (const where of ["mid", "start", "end"] as const) {
test(`undo ink accounting - caret ${where}`, async ({ page }) => {
test.setTimeout(180_000);
await open(page);
const run = page
.locator('[data-testid^="pdf-editor-run-p0-"]')
.filter({ hasText: /First line of the body/ })
.first();
const testId = (await run.getAttribute("data-testid")) ?? "";
const runId = testId.replace("pdf-editor-run-", "");
const box = page.locator(`[data-testid="${testId}"]`);
const base = await inkOf(page, testId, runId);
expect(base, "no canvas ink reading").not.toBeNull();
await box.click();
await page.waitForTimeout(500);
await page.evaluate(
({ id, w }: { id: string; w: string }) => {
const el = document.querySelector<HTMLElement>(
`[data-testid="${id}"]`,
)!;
el.focus();
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
const node = walker.nextNode()!;
const len = (node.nodeValue ?? "").length;
const off = w === "end" ? len : w === "start" ? 0 : Math.floor(len / 2);
const sel = window.getSelection()!;
const r = document.createRange();
r.setStart(node, off);
r.collapse(true);
sel.removeAllRanges();
sel.addRange(r);
},
{ id: testId, w: where },
);
await page.waitForTimeout(300);
await page.keyboard.type("ZZZZ", { delay: 60 });
await page.waitForTimeout(1200);
await page.evaluate((id: string) => {
document.querySelector<HTMLElement>(`[data-testid="${id}"]`)?.blur();
}, testId);
await page.waitForTimeout(2500);
const edited = await inkOf(page, testId, runId);
expect(edited!.text, "the edit did not land").toContain("ZZZZ");
await page.getByTestId("pdf-editor-undo").click();
await page.waitForTimeout(3000);
const undone = await inkOf(page, testId, runId);
// eslint-disable-next-line no-console
console.log(
`INKREPORT ${where}: base=${base!.dark} edited=${edited!.dark} undone=${undone!.dark} delta=${undone!.dark - base!.dark} textRestored=${undone!.text === base!.text}`,
);
expect(
Math.abs(undone!.dark - base!.dark),
`undo left ${undone!.dark - base!.dark}px of ink vs base (base=${base!.dark}, edited=${edited!.dark})`,
).toBeLessThan(base!.dark * 0.02);
});
}
@@ -0,0 +1,164 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import path from "path";
import type { EditorTestWindow } from "@app/tests/stubbed/editorTestTypes";
// Guards focus theft after an edit. A window selection outlives the blur that
// ends an edit, and putting a range back into a contenteditable FOCUSES it, so
// an unconditional caret restore hands the cursor back to a run the user has
// already left - taking their typing with it.
//
// Reading the caret from the selection is still right: replaceChildren
// detaches the node it sits in. Writing it back to an unfocused run is not.
const SAMPLE = path.join(
import.meta.dirname,
"../../../../public/samples/Sample.pdf",
);
// Past the 400ms settle and the 600ms model resync, which are the repaints that
// used to hand the run its focus back.
const PAST_SETTLE_MS = 2000;
async function open(page: Page): Promise<void> {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 15_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(SAMPLE);
await expect(page.getByTestId("pdf-editor-page-1")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(900);
}
async function findId(page: Page): Promise<string> {
const id = await page.evaluate(() => {
const s = (window as unknown as EditorTestWindow).__editor_store;
const r = s.doc
.page(1)
.runs.find((x) => /Stirling\s+PDF\s+is\s+a\s+robust/.test(x.text));
return r ? r.id : "";
});
expect(id, "fixture paragraph not found").not.toBe("");
return id;
}
async function caretEndInsert(page: Page, id: string, text: string) {
await page.evaluate(
({ id, text }: { id: string; text: string }) => {
const el = document.querySelector<HTMLDivElement>(
`[data-testid="pdf-editor-run-${id}"]`,
)!;
el.focus();
const sel = window.getSelection()!;
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
document.execCommand("insertText", false, text);
},
{ id, text },
);
await page.waitForTimeout(150);
}
/** Click blank page chrome - a real click-away, not a programmatic blur. */
async function clickAway(page: Page) {
await page
.getByTestId("pdf-editor-page-1")
.click({ position: { x: 4, y: 4 } });
}
function focusState(page: Page, id: string) {
return page.evaluate((rid: string) => {
const el = document.querySelector(`[data-testid="pdf-editor-run-${rid}"]`);
const sel = window.getSelection();
return {
runHasFocus: !!(el && el.contains(document.activeElement)),
activeTestId: document.activeElement?.getAttribute("data-testid") ?? null,
// A selection left in a blurred run is what WebKit keeps typing into.
selectionInRun: !!(sel?.focusNode && el && el.contains(sel.focusNode)),
};
}, id);
}
function runText(page: Page, id: string): Promise<string> {
return page.evaluate((rid: string) => {
const r = (window as unknown as EditorTestWindow).__editor_store.doc
.page(1)
.runs.find((x) => x.id === rid);
return r ? (r.text as string) : "(gone)";
}, id);
}
test.describe("PDF text editor - an edited run lets go of focus", () => {
test("clicking away from an edited run leaves it unfocused", async ({
page,
}) => {
await open(page);
const id = await findId(page);
await caretEndInsert(page, id, " UNIQ");
await clickAway(page);
// Immediately after the click the focus is correctly gone. The theft only
// lands on the repaint that follows, so the wait is the whole point.
expect((await focusState(page, id)).runHasFocus).toBe(false);
await page.waitForTimeout(PAST_SETTLE_MS);
const state = await focusState(page, id);
// The blur handler drops the run's selection with its focus - WebKit
// otherwise keeps routing keystrokes into it (see the next test).
expect(
state.selectionInRun,
"a blurred run kept its selection, which WebKit still types into",
).toBe(false);
expect(
state.runHasFocus,
`a run the user clicked away from took the cursor back (active=${state.activeTestId})`,
).toBe(false);
});
test("typing after clicking away does not land in the run just left", async ({
page,
}) => {
await open(page);
const id = await findId(page);
await caretEndInsert(page, id, " UNIQ");
await clickAway(page);
await page.waitForTimeout(PAST_SETTLE_MS);
const before = await runText(page, id);
await page.keyboard.type("ZZZ", { delay: 40 });
await page.waitForTimeout(1500);
expect(
await runText(page, id),
"keystrokes went into a paragraph the user had already left",
).toBe(before);
});
test("a repaint after an edit does not re-seat the caret in a blurred run", async ({
page,
}) => {
await open(page);
const id = await findId(page);
await caretEndInsert(page, id, " UNIQ");
await clickAway(page);
await page.waitForTimeout(PAST_SETTLE_MS);
// Force a further repaint the way a model change would.
await page.evaluate(() =>
(window as unknown as EditorTestWindow).__editor_store.resetAll(),
);
await page.waitForTimeout(800);
expect(
(await focusState(page, id)).runHasFocus,
"a model-change repaint handed focus back to a blurred run",
).toBe(false);
});
});
@@ -0,0 +1,180 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import path from "path";
// The italic button answered for every font, including ones we cannot make
// italic. Its handler ended in `?? helveticaWith(...)`, so clicking it on a
// subset-embedded face threw the document's typeface away and replaced it with
// Helvetica-Oblique - silently, and with the button looking perfectly enabled.
//
// A style we cannot actually produce must not be offered.
const SUBSET_PDF = path.join(
import.meta.dirname,
"../test-fixtures/subset-font-sample.pdf",
);
const PARAGRAPH_PDF = path.join(
import.meta.dirname,
"../test-fixtures/paragraph-sample.pdf",
);
async function openEditor(page: import("@playwright/test").Page, file: string) {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(file);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 60_000,
});
await page.waitForTimeout(1500);
}
interface RunInfo {
id: string;
fontId: string;
}
/** Every run on page 0, with the font id the model holds for it. */
function readRuns(page: import("@playwright/test").Page): Promise<RunInfo[]> {
return page.evaluate(() => {
const w = window as unknown as {
__editor_store: {
state: { pages: { runs: { id: string; fontId: string }[] }[] };
};
};
return w.__editor_store.state.pages[0].runs.map((r) => ({
id: r.id,
fontId: r.fontId,
}));
});
}
function fontIdOf(
page: import("@playwright/test").Page,
runId: string,
): Promise<string | null> {
return page.evaluate((rid) => {
const w = window as unknown as {
__editor_store: {
state: { pages: { runs: { id: string; fontId: string }[] }[] };
};
};
for (const p of w.__editor_store.state.pages) {
const r = p.runs.find((x) => x.id === rid);
if (r) return r.fontId;
}
return null;
}, runId);
}
test.describe("PDF text editor - font capability gating", () => {
test("italic is disabled for a font that has no italic version", async ({
page,
}) => {
await openEditor(page, SUBSET_PDF);
const runs = await readRuns(page);
// Not base-14 and no device fonts loaded: there is no italic cut to reach.
const embedded = runs.find((r) => !/^(base14|device):/.test(r.fontId));
expect(
embedded,
`fixture should hold an embedded font run, got ${runs.map((r) => r.fontId).join(", ")}`,
).toBeTruthy();
await page
.locator(`[data-testid="pdf-editor-run-${embedded!.id}"]`)
.click();
await page.waitForTimeout(400);
const italic = page.getByTestId("pdf-editor-italic");
await expect(italic).toBeVisible();
await expect(
italic,
`italic offered for ${embedded!.fontId}, which has no italic face`,
).toBeDisabled();
});
test("says WHY italic is unavailable", async ({ page }) => {
await openEditor(page, SUBSET_PDF);
const runs = await readRuns(page);
const embedded = runs.find((r) => !/^(base14|device):/.test(r.fontId));
expect(embedded).toBeTruthy();
await page
.locator(`[data-testid="pdf-editor-run-${embedded!.id}"]`)
.click();
await page.waitForTimeout(400);
const italic = page.getByTestId("pdf-editor-italic");
await expect(italic).toBeDisabled();
// Chromium drops events aimed AT a disabled button, so the tooltip only
// survives because the pointer lands on the icon child and bubbles.
await italic.hover();
await page.waitForTimeout(1000);
const tips = await page.evaluate(() =>
[...document.querySelectorAll(".mantine-Tooltip-tooltip")]
.map((n) => (n as HTMLElement).innerText)
.join(" | "),
);
expect(
tips,
"a disabled control with no explanation just looks broken",
).toContain("no italic version");
});
test("italic never swaps an embedded font for Helvetica", async ({
page,
}) => {
await openEditor(page, SUBSET_PDF);
const runs = await readRuns(page);
const embedded = runs.find((r) => !/^(base14|device):/.test(r.fontId));
expect(embedded).toBeTruthy();
const before = embedded!.fontId;
await page
.locator(`[data-testid="pdf-editor-run-${embedded!.id}"]`)
.click();
await page.waitForTimeout(400);
// force: the point is that a disabled button does nothing. On the old code
// the button was enabled and this click rewrote the font.
await page.getByTestId("pdf-editor-italic").click({ force: true });
await page.waitForTimeout(1200);
const after = await fontIdOf(page, embedded!.id);
expect(
after,
`"italic" replaced ${before} with ${after} - a different typeface, not a slant`,
).toBe(before);
});
test("italic still works once the run is on a base-14 family", async ({
page,
}) => {
await openEditor(page, PARAGRAPH_PDF);
const runs = await readRuns(page);
expect(runs.length).toBeGreaterThan(0);
const target = runs[0];
await page.locator(`[data-testid="pdf-editor-run-${target.id}"]`).click();
await page.waitForTimeout(400);
// Picking a standard family is the documented way out: from there the
// italic cut genuinely exists, so the control must come back.
const picker = page.getByTestId("pdf-editor-font-family");
await picker.click();
await page.getByRole("option", { name: "Helvetica", exact: true }).click();
await page.waitForTimeout(1500);
const italic = page.getByTestId("pdf-editor-italic");
await expect(italic).toBeEnabled();
await italic.click();
await page.waitForTimeout(1500);
expect(await fontIdOf(page, target.id)).toMatch(/italic|oblique/i);
});
});
@@ -0,0 +1,55 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page, Route } from "@playwright/test";
import path from "path";
// End-to-end coverage for the fonts panel's client-side glyph-coverage probe.
const SUBSET = path.join(
import.meta.dirname,
"../test-fixtures/subset-font-sample.pdf",
);
async function open(page: Page, file: string): Promise<void> {
await page.route("**/encode-charcodes", (route: Route) => route.abort());
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 20_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(file);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(1500);
}
test("fonts panel reports concrete a-zA-Z0-9 coverage gaps client-side", async ({
page,
}) => {
test.setTimeout(90_000);
const errs: string[] = [];
page.on("pageerror", (e) => errs.push(e.message));
await open(page, SUBSET);
// The editor must still render (the canvas page) - reading font data at load
// must not corrupt PDFium.
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible();
await page.getByTestId("pdf-editor-tab-document").click();
const panel = page.getByTestId("pdf-editor-fonts-panel");
await expect(panel).toBeVisible();
// A subset TrueType font with a parseable cmap => concrete missing chars.
const missing = panel.getByTestId("pdf-editor-font-missing").first();
await expect(missing).toBeVisible();
await expect(missing).toContainText(/Missing:/);
// ...and the summary escalates to the yellow "warn" tone accordingly.
await expect(panel.getByTestId("pdf-editor-font-compat")).toHaveAttribute(
"data-compat",
"warn",
);
expect(errs, `no page errors:\n${errs.join("\n")}`).toEqual([]);
});
@@ -0,0 +1,120 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import path from "path";
// Selecting text set in an embedded or subset face left the font picker showing
// its "Font family" placeholder: the editor knew the run was NotoSubset and told
// the user nothing. Worse, the blank box reads as "no font", inviting a pick that
// substitutes Helvetica for a typeface the document already had.
//
// The picker must name the font it recognised, and must not offer it as a
// re-selectable option unless the real face is actually loadable.
const SUBSET_PDF = path.join(
import.meta.dirname,
"../test-fixtures/subset-font-sample.pdf",
);
async function openEditor(page: import("@playwright/test").Page, file: string) {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(file);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 60_000,
});
await page.waitForTimeout(1500);
}
/** The first run on page 0 whose font is neither base-14 nor a device face. */
function embeddedRun(
page: import("@playwright/test").Page,
): Promise<{ id: string; fontId: string } | null> {
return page.evaluate(() => {
const w = window as unknown as {
__editor_store: {
state: { pages: { runs: { id: string; fontId: string }[] }[] };
};
};
const runs = w.__editor_store.state.pages[0].runs;
const hit = runs.find((r) => !/^(base14|device):/.test(r.fontId));
return hit ? { id: hit.id, fontId: hit.fontId } : null;
});
}
test.describe("PDF text editor - document font recognition", () => {
test("names the document's own font instead of showing a blank picker", async ({
page,
}) => {
await openEditor(page, SUBSET_PDF);
const run = await embeddedRun(page);
expect(run, "fixture should hold an embedded font run").toBeTruthy();
// "pdf:<ptr>:<family>" - the part the user should actually be shown.
const family = run!.fontId.slice(run!.fontId.lastIndexOf(":") + 1);
await page.locator(`[data-testid="pdf-editor-run-${run!.id}"]`).click();
await page.waitForTimeout(400);
const picker = page.getByTestId("pdf-editor-font-family");
await expect(picker).toBeVisible();
await expect(
picker,
`picker hid the recognised font ${run!.fontId}, leaving the user guessing`,
).toHaveValue(new RegExp(family.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
});
test("does not offer the unloadable document font as a pick", async ({
page,
}) => {
await openEditor(page, SUBSET_PDF);
const run = await embeddedRun(page);
expect(run).toBeTruthy();
const before = run!.fontId;
const family = before.slice(before.lastIndexOf(":") + 1);
await page.locator(`[data-testid="pdf-editor-run-${run!.id}"]`).click();
await page.waitForTimeout(400);
// The recognised face must be listed - that is the recognition - and the
// entry must be inert, since we hold no bytes to actually re-emit with.
await page.getByTestId("pdf-editor-font-family").click();
await page.waitForTimeout(400);
const entry = page
.getByRole("option", { name: new RegExp(`^${family}$`) })
.first();
await expect(
entry,
`the recognised font ${family} was not listed at all`,
).toBeVisible();
await expect(
entry,
`${family} was offered as a pick, but we hold no bytes for it`,
).toHaveAttribute("data-combobox-disabled", "true");
await entry.click({ force: true });
await page.waitForTimeout(1000);
await page.keyboard.press("Escape");
await page.waitForTimeout(600);
const after = await page.evaluate((rid) => {
const w = window as unknown as {
__editor_store: {
state: { pages: { runs: { id: string; fontId: string }[] }[] };
};
};
for (const p of w.__editor_store.state.pages) {
const r = p.runs.find((x) => x.id === rid);
if (r) return r.fontId;
}
return null;
}, run!.id);
expect(
after,
`picking the recognised font rewrote ${before} to ${after}`,
).toBe(before);
});
});
@@ -0,0 +1,100 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import path from "path";
// The page bitmap must render at devicePixelRatio x the zoom scale, or every
// HiDPI display shows a browser-upscaled blur ("it looks low res"). The CSS
// layout size must NOT follow the ratio - overlays, clicks and geometry are
// all CSS-based and would drift if it did.
const SAMPLE = path.join(
import.meta.dirname,
"../../../../public/samples/Sample.pdf",
);
async function open(page: Page): Promise<void> {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(SAMPLE);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 60_000,
});
await page.waitForTimeout(1000);
}
interface CanvasGeom {
bitmapW: number;
bitmapH: number;
cssW: number;
cssH: number;
pageWidthPt: number;
renderScale: number;
dpr: number;
}
function canvasGeom(page: Page): Promise<CanvasGeom> {
return page.evaluate(() => {
const el = document.querySelector('[data-testid="pdf-editor-page-0"]')!;
const canvas = el.querySelector("canvas") as HTMLCanvasElement;
const cb = canvas.getBoundingClientRect();
const store = (
window as unknown as {
__editor_store: {
getState(): {
renderScale: number;
pages: { width: number }[];
};
};
}
).__editor_store;
const st = store.getState();
return {
bitmapW: canvas.width,
bitmapH: canvas.height,
cssW: cb.width,
cssH: cb.height,
pageWidthPt: st.pages[0].width,
renderScale: st.renderScale,
dpr: window.devicePixelRatio || 1,
};
});
}
test.describe("PDF text editor - HiDPI rendering", () => {
test.describe("on a 2x display", () => {
test.use({ deviceScaleFactor: 2, viewport: { width: 1440, height: 900 } });
test("the bitmap carries 2x the pixels of its CSS box", async ({
page,
}) => {
await open(page);
const g = await canvasGeom(page);
expect(g.dpr).toBe(2);
// Layout stays at the zoom scale...
expect(g.cssW).toBeCloseTo(g.pageWidthPt * g.renderScale, 0);
// ...while the bitmap renders at zoom x ratio - the whole fix.
expect(g.bitmapW).toBe(
Math.max(1, Math.round(g.pageWidthPt * g.renderScale * 2)),
);
expect(g.bitmapW / g.cssW).toBeCloseTo(2, 1);
});
});
test.describe("on a 1x display", () => {
test.use({ deviceScaleFactor: 1, viewport: { width: 1440, height: 900 } });
test("the bitmap matches the CSS box", async ({ page }) => {
await open(page);
const g = await canvasGeom(page);
expect(g.dpr).toBe(1);
expect(g.bitmapW).toBe(
Math.max(1, Math.round(g.pageWidthPt * g.renderScale)),
);
expect(g.bitmapW / g.cssW).toBeCloseTo(1, 1);
});
});
});
@@ -0,0 +1,57 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page, Route } from "@playwright/test";
import path from "path";
// Inserting a JPEG embeds the original JPEG stream instead of re-encoding
// decoded RGBA pixels, so the output stays small.
const SAMPLE_PDF = path.join(
import.meta.dirname,
"../test-fixtures/sample.pdf",
);
const SAMPLE_JPG = path.join(
import.meta.dirname,
"../test-fixtures/sample.jpg",
);
test("inserting a JPEG embeds it as DCTDecode, not re-encoded RGBA", async ({
page,
}: {
page: Page;
}) => {
test.setTimeout(90_000);
await page.route("**/encode-charcodes", (route: Route) => route.abort());
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 20_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(SAMPLE_PDF);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(500);
// Insert the JPEG via the editor's image input.
await page
.locator('[data-testid="pdf-editor-image-input"]')
.setInputFiles(SAMPLE_JPG);
await page.waitForTimeout(1500);
// Save and scan the bytes for the JPEG (DCTDecode) filter.
const downloadPromise = page.waitForEvent("download");
await page.getByTestId("pdf-editor-download").click();
const dl = await downloadPromise;
const stream = await dl.createReadStream();
const chunks: Buffer[] = [];
for await (const c of stream) chunks.push(c as Buffer);
const saved = Buffer.concat(chunks);
const asText = saved.toString("latin1");
expect(saved.subarray(0, 4).toString("ascii")).toBe("%PDF");
expect(
asText.includes("DCTDecode"),
"saved PDF embeds the JPEG as DCTDecode (passthrough)",
).toBe(true);
});
@@ -0,0 +1,535 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import path from "path";
import type { EditorTestWindow } from "@app/tests/stubbed/editorTestTypes";
import { downloadBytes, saveAndDownload } from "@app/tests/stubbed/saveHelpers";
// REGRESSION suite for the 12 issues found by the QA sweep of the PDF text
// editor - all since fixed.
const SAMPLE = path.join(
import.meta.dirname,
"../../../../public/samples/Sample.pdf",
);
const SUBSET = path.join(
import.meta.dirname,
"../test-fixtures/subset-font-sample.pdf",
);
async function open(page: Page, file: string, firstPage = 0): Promise<void> {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 15_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(file);
await expect(page.getByTestId(`pdf-editor-page-${firstPage}`)).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(900);
}
async function findId(
page: Page,
pageIdx: number,
src: string,
): Promise<string> {
const id = await page.evaluate(
({ pageIdx, src }: { pageIdx: number; src: string }) => {
const s = (window as unknown as EditorTestWindow).__editor_store;
const r = s.doc
.page(pageIdx)
.runs.find((x) => new RegExp(src).test(x.text));
return r ? r.id : null;
},
{ pageIdx, src },
);
if (!id) throw new Error(`run /${src}/ not found on page ${pageIdx}`);
return id;
}
async function leafPtrs(
page: Page,
pageIdx: number,
id: string,
): Promise<number[]> {
return page.evaluate(
({ pageIdx, id }: { pageIdx: number; id: string }) => {
const s = (window as unknown as EditorTestWindow).__editor_store;
const r = s.doc.page(pageIdx).runs.find((x) => x.id === id);
return r ? [...r.paragraphLeafPtrs] : [];
},
{ pageIdx, id },
);
}
async function caretEndInsert(
page: Page,
id: string,
text: string,
): Promise<void> {
await page.evaluate(
({ id, text }: { id: string; text: string }) => {
const el = document.querySelector<HTMLDivElement>(
`[data-testid="pdf-editor-run-${id}"]`,
)!;
el.focus();
const sel = window.getSelection()!;
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
document.execCommand("insertText", false, text);
},
{ id, text },
);
await page.waitForTimeout(150);
}
async function replaceAll(page: Page, id: string, full: string): Promise<void> {
await page.evaluate(
({ id, full }: { id: string; full: string }) => {
const el = document.querySelector<HTMLDivElement>(
`[data-testid="pdf-editor-run-${id}"]`,
)!;
el.focus();
const sel = window.getSelection()!;
const range = document.createRange();
range.selectNodeContents(el);
sel.removeAllRanges();
sel.addRange(range);
document.execCommand("insertText", false, full);
},
{ id, full },
);
await page.waitForTimeout(200);
}
async function blur(page: Page, id: string): Promise<void> {
await page.evaluate((rid: string) => {
document
.querySelector<HTMLElement>(`[data-testid="pdf-editor-run-${rid}"]`)
?.blur();
}, id);
await page.waitForTimeout(1200);
}
/** Per-glyph geometry + extracted text for a run, straight from PDFium. */
async function glyphs(
page: Page,
pageIdx: number,
id: string,
): Promise<{
boundsRight: number;
pageWidth: number;
fontId: string;
text: string;
maxGap: number;
hasYdieresis: boolean;
}> {
return page.evaluate(
({ pageIdx, id }: { pageIdx: number; id: string }) => {
const s = (window as unknown as EditorTestWindow).__editor_store;
const m = s.doc.module;
const pg = s.doc.page(pageIdx);
pg.flushGenerate(m);
const r = pg.runs.find((x) => x.id === id)!;
const ptrs: number[] = r.paragraphLeafPtrs.length
? r.paragraphLeafPtrs
: r.mergedFromPtrs.length
? r.mergedFromPtrs
: [r.pdfiumObjPtr];
const tp = m.FPDFText_LoadPage(pg.pagePtr);
const seg: Array<{ x: number; right: number; base: number }> = [];
let hasY = false;
try {
for (const ptr of ptrs) {
if (!ptr) continue;
const l = m.pdfium.wasmExports.malloc(4);
const b = m.pdfium.wasmExports.malloc(4);
const rr = m.pdfium.wasmExports.malloc(4);
const t = m.pdfium.wasmExports.malloc(4);
const mat = m.pdfium.wasmExports.malloc(24);
try {
if (!m.FPDFPageObj_GetBounds(ptr, l, b, rr, t)) continue;
let base = m.pdfium.getValue(b, "float");
if (m.FPDFPageObj_GetMatrix(ptr, mat))
base = m.pdfium.getValue(mat + 20, "float");
seg.push({
x: m.pdfium.getValue(l, "float"),
right: m.pdfium.getValue(rr, "float"),
base: Math.round(base),
});
const len = m.FPDFTextObj_GetText(ptr, tp, 0, 0);
if (len > 2) {
const buf = m.pdfium.wasmExports.malloc(len);
try {
m.FPDFTextObj_GetText(ptr, tp, buf, len);
let str = "";
for (let o = 0; o < len - 2; o += 2)
str += String.fromCharCode(m.pdfium.getValue(buf + o, "i16"));
if (str.includes("ÿ")) hasY = true;
} finally {
m.pdfium.wasmExports.free(buf);
}
}
} finally {
m.pdfium.wasmExports.free(l);
m.pdfium.wasmExports.free(b);
m.pdfium.wasmExports.free(rr);
m.pdfium.wasmExports.free(t);
m.pdfium.wasmExports.free(mat);
}
}
} finally {
m.FPDFText_ClosePage(tp);
}
// Largest gap between consecutive glyphs on the TOP line.
const top = seg.length ? Math.max(...seg.map((g) => g.base)) : 0;
const line = seg
.filter((g) => Math.abs(g.base - top) <= 2)
.sort((a, b) => a.x - b.x);
let maxGap = 0;
for (let i = 1; i < line.length; i++) {
maxGap = Math.max(maxGap, line[i].x - line[i - 1].right);
}
return {
boundsRight: r.bounds.x + r.bounds.width,
pageWidth: pg.width,
fontId: r.fontId as string,
text: (r.text as string) ?? "",
maxGap,
hasYdieresis: hasY,
};
},
{ pageIdx, id },
);
}
test.describe("PDF text editor - fixed-issue regressions", () => {
// ISSUE: a single-line run grows past the right page edge when you type a
// long string.
test("typing a long string into a single-line run stays on the page", async ({
page,
}) => {
await open(page, SAMPLE, 0);
const id = await findId(page, 0, "Adobe.*Acrobat.*Alternative");
await caretEndInsert(
page,
id,
" plus a very long appended tail that keeps going and going and going",
);
await blur(page, id);
const g = await glyphs(page, 0, id);
expect(
g.boundsRight,
"run must not extend past the page width",
).toBeLessThanOrEqual(g.pageWidth + 2);
});
// ISSUE: typing non-Latin text into a paragraph re-emits the WHOLE paragraph,
// so every original line loses its source font objects.
test("typing non-Latin text keeps the paragraph's other lines' objects", async ({
page,
}) => {
await open(page, SAMPLE, 1);
const id = await findId(page, 1, "Stirling\\s+PDF\\s+is\\s+a\\s+robust");
const before = await leafPtrs(page, 1, id);
await caretEndInsert(page, id, " 日本語 🎉");
await blur(page, id);
const after = await leafPtrs(page, 1, id);
const kept = before.filter((p) => after.includes(p)).length;
expect(
kept,
"unedited lines must keep their objects when non-Latin text is added",
).toBe(before.length);
});
// ISSUE: non-Latin glyphs render as U+00FF (ydieresis "ÿ") tofu because the
// base-14 fallback font has no glyph for them.
test("typing non-Latin text does not render as ydieresis tofu", async ({
page,
}) => {
await open(page, SAMPLE, 1);
const id = await findId(page, 1, "Stirling\\s+PDF\\s+is\\s+a\\s+robust");
await caretEndInsert(page, id, " 日本語");
await blur(page, id);
const g = await glyphs(page, 1, id);
expect(g.hasYdieresis, "CJK must not be replaced by 'ÿ' tofu glyphs").toBe(
false,
);
});
// ISSUE: typing an emoji then saving must round-trip the FULL surrogate pair.
test("typing an emoji round-trips without a lone surrogate or U+00FF tofu", async ({
page,
}) => {
test.setTimeout(120_000);
await open(page, SAMPLE, 1);
const id = await findId(page, 1, "Stirling\\s+PDF\\s+is\\s+a\\s+robust");
await caretEndInsert(page, id, " 🎉");
await blur(page, id);
// Save, then reopen the produced bytes. The emoji is unrepresentable, so
// it is dropped and the save-risk modal always gates the save.
const saved = await downloadBytes(await saveAndDownload(page, true));
await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({
name: "emoji-round-trip.pdf",
mimeType: "application/pdf",
buffer: saved,
});
await expect(
page.locator('[data-testid^="pdf-editor-run-p1-"]').first(),
).toBeVisible({ timeout: 30_000 });
await page.waitForTimeout(500);
const reopened = await page.evaluate(() => {
const s = (window as unknown as EditorTestWindow).__editor_store;
return s.doc
.page(1)
.runs.map((r) => r.text)
.join("");
});
// No lone high surrogate (one not immediately followed by a low surrogate).
expect(
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(reopened),
"saved model must not contain a lone surrogate",
).toBe(false);
expect(
reopened.includes("ÿ"),
"emoji must not be replaced by 'ÿ' tofu",
).toBe(false);
});
// NOTE: two more issues are VISUALLY confirmed but omitted here because a
// stable automated assertion is hard.
// ISSUE: deleting the LEADING word of a paragraph line injects a stray
// U+00FF ("ÿ") into the model text.
test("deleting the leading word does not inject a U+00FF artifact", async ({
page,
}) => {
await open(page, SAMPLE, 1);
const id = await findId(page, 1, "Comprehensive\\s+toolkit");
const cur = await page.evaluate(
(rid: string) =>
(window as unknown as EditorTestWindow).__editor_store.doc
.page(1)
.runs.find((x) => x.id === rid)!.text as string,
id,
);
await replaceAll(page, id, cur.replace(/^Comprehensive/, ""));
await blur(page, id);
const g = await glyphs(page, 1, id);
expect(g.text.includes("ÿ"), "model text must not contain 'ÿ'").toBe(false);
});
// ISSUE: deleting a single character from the MIDDLE of a Type3 word leaves a
// spurious space at the deletion point.
test("deleting a mid-word character does not inject a spurious space", async ({
page,
}) => {
await open(page, SAMPLE, 1);
const id = await findId(page, 1, "Comprehensive\\s+toolkit");
const cur = await page.evaluate(
(rid: string) =>
(window as unknown as EditorTestWindow).__editor_store.doc
.page(1)
.runs.find((x) => x.id === rid)!.text as string,
id,
);
// drop the 'h' from "Comprehensive" -> the word should read "Compreensive"
await replaceAll(page, id, cur.replace("Comprehensive", "Compreensive"));
await blur(page, id);
const g = await glyphs(page, 1, id);
expect(
g.text.includes("Compre ensive"),
"mid-word delete must not split the word with a space",
).toBe(false);
});
// ISSUE: inserting an image through the toolbar picker does not add an image
// to the page.
test("inserting an image via the picker adds an image to the page", async ({
page,
}) => {
await open(page, SAMPLE, 0);
const countImages = () =>
page.evaluate(() => {
const s = (window as unknown as EditorTestWindow).__editor_store;
return s.doc
.loadedPages()
.reduce((n: number, p) => n + p.images.length, 0);
});
const before = await countImages();
// a 1x1 red PNG, decoded in-browser by handleInsertImage
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64",
);
await page.locator('[data-testid="pdf-editor-image-input"]').setInputFiles({
name: "dot.png",
mimeType: "image/png",
buffer: png,
});
await page.waitForTimeout(1800);
const after = await countImages();
expect(after, "image insert must add an image to the page").toBeGreaterThan(
before,
);
});
// ISSUE: injecting several consecutive spaces into a multi-line paragraph
// collapses them.
test("injecting consecutive spaces into a paragraph preserves them", async ({
page,
}) => {
await open(page, SAMPLE, 1);
const id = await findId(page, 1, "Stirling\\s+PDF\\s+is\\s+a\\s+robust");
const cur = await page.evaluate(
(rid: string) =>
(window as unknown as EditorTestWindow).__editor_store.doc
.page(1)
.runs.find((x) => x.id === rid)!.text as string,
id,
);
await replaceAll(
page,
id,
cur.replace(/Stirling\s+PDF/, "Stirling PDF"),
);
await blur(page, id);
const g = await glyphs(page, 1, id);
expect(
/Stirling {5}PDF/.test(g.text),
"five consecutive injected spaces must survive into the model",
).toBe(true);
});
// ISSUE: redo after undo-all does NOT reproduce the text the original edit
// produced.
test("redo after undo-all reproduces the originally edited text", async ({
page,
}) => {
await open(page, SAMPLE, 1);
const id = await findId(page, 1, "Stirling\\s+PDF\\s+is\\s+a\\s+robust");
const get = (): Promise<string> =>
page.evaluate((r: string) => {
const x = (window as unknown as EditorTestWindow).__editor_store.doc
.page(1)
.runs.find((y) => y.id === r);
return x ? (x.text as string) : "(gone)";
}, id);
await caretEndInsert(page, id, " UNIQ");
await blur(page, id);
const edited = await get();
await page.evaluate(() =>
(window as unknown as EditorTestWindow).__editor_store.resetAll(),
);
await page.waitForTimeout(400);
// Redo until the button is disabled.
for (let i = 0; i < 6; i++) {
const redoBtn = page.getByTestId("pdf-editor-redo");
if (await redoBtn.isDisabled()) break;
await redoBtn.click();
await page.waitForTimeout(120);
}
const redone = await get();
expect(
redone,
"redo must reproduce the exact text the original edit produced",
).toBe(edited);
});
// ISSUE / LIMITATION: editing embedded / subset / form-xobject text used to
// re-font the run to base-14 Helvetica even when the edit only adds.
test("editing a subset-font run keeps a non-base-14 font", async ({
page,
}) => {
await open(page, SUBSET, 0);
const id = await findId(page, 0, "Subset font sample");
await caretEndInsert(page, id, "test");
await blur(page, id);
const g = await glyphs(page, 0, id);
expect(
g.fontId.startsWith("base14:"),
"subset edit flips to Helvetica",
).toBe(false);
});
// ISSUE / UX: a single Enter-then-type produces several `input` events, so
// several undo steps are needed to revert one logical edit.
test("one undo reverts a single Enter+type action", async ({ page }) => {
await open(page, SAMPLE, 1);
const id = await findId(page, 1, "Stirling\\s+PDF\\s+is\\s+a\\s+robust");
const before = await page.evaluate(
(rid: string) =>
(window as unknown as EditorTestWindow).__editor_store.doc
.page(1)
.runs.find((x) => x.id === rid)!.text as string,
id,
);
await caretEndInsert(page, id, "\n");
await caretEndInsert(page, id, "Z");
// Pause past the history coalesce window before clicking away, the way a
// real user does.
await page.waitForTimeout(1200);
await page.getByTestId("pdf-editor-undo").click();
// Poll for the revert rather than a fixed wait - the undo's store update can
// lag the click under load, which made a single fixed timeout flaky.
await expect
.poll(
() =>
page.evaluate(
(rid: string) =>
((window as unknown as EditorTestWindow).__editor_store.doc
.page(1)
.runs.find((x) => x.id === rid)?.text ?? null) as string | null,
id,
),
{ timeout: 6000, message: "one undo should fully revert Enter+type" },
)
.toBe(before);
});
// ISSUE: opening an ENCRYPTED PDF fails silently - the editor shows "No
// document loaded" with no error message and no password prompt.
test("opening an encrypted PDF surfaces an error or password prompt", async ({
page,
}) => {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 15_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(
path.join(import.meta.dirname, "../test-fixtures/encrypted.pdf"),
);
await page.waitForTimeout(2500);
const loaded = await page.getByTestId("pdf-editor-page-0").count();
const error = await page.getByTestId("pdf-editor-error").count();
const prompt = await page.getByTestId("pdf-editor-password-modal").count();
expect(
loaded > 0 || error > 0 || prompt > 0,
"an encrypted PDF must either open or tell the user why it can't",
).toBe(true);
});
// ISSUE: opening a CORRUPTED PDF fails silently - same "No document loaded"
// with no error feedback.
test("opening a corrupted PDF surfaces an error", async ({ page }) => {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 15_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(
path.join(import.meta.dirname, "../test-fixtures/corrupted.pdf"),
);
await page.waitForTimeout(2500);
const loaded = await page.getByTestId("pdf-editor-page-0").count();
const error = await page.getByTestId("pdf-editor-error").count();
expect(
loaded > 0 || error > 0,
"a corrupted PDF must surface an error instead of failing silently",
).toBe(true);
});
});
@@ -0,0 +1,142 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page, Route } from "@playwright/test";
import path from "path";
import type { EditorTestWindow } from "@app/tests/stubbed/editorTestTypes";
// Letter-spacing (Tc) preservation through an edit. letter-spacing-sample.pdf
// has an 18pt "SPACED HEADING" drawn with `2 Tc` plus a normal 12pt body line.
const FIXTURE = path.join(
import.meta.dirname,
"../test-fixtures/letter-spacing-sample.pdf",
);
test("editing a letter-spaced heading keeps its tracking through save+reopen", async ({
page,
}: {
page: Page;
}) => {
test.setTimeout(120_000);
const errs: string[] = [];
page.on("pageerror", (e) => errs.push(e.message));
// Serve real charcodes for the standard-14 Helvetica: its charcode IS the
// ASCII code, so the per-char backend branch engages like production.
await page.route("**/encode-charcodes", async (route: Route) => {
let text = "";
try {
const body = route.request().postDataJSON() as { text?: string };
text = body.text ?? "";
} catch {
/* fall through with empty text */
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
charcodes: [...text].map((c) => c.codePointAt(0) ?? 0),
missing: [],
note: "stub ascii",
}),
});
});
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 20_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(FIXTURE);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(800);
const readRuns = () =>
page.evaluate(() => {
const s = (window as unknown as EditorTestWindow).__editor_store;
return s.doc.page(0).runs.map((r) => ({
id: r.id,
text: r.text,
charSpacingPt: r.charSpacingPt,
}));
});
const before = await readRuns();
const heading = before.find((r) => r.text.includes("SPACED"));
const body = before.find((r) => r.text.includes("Normal"));
expect(heading, `heading run in ${JSON.stringify(before)}`).toBeTruthy();
expect(body, "body run found").toBeTruthy();
// Inference reads ~2pt for the spaced heading, 0 for the normal line.
expect(heading!.charSpacingPt).toBeGreaterThan(1.4);
expect(heading!.charSpacingPt).toBeLessThan(2.6);
expect(body!.charSpacingPt).toBe(0);
// Focus (prewarm), then delete the "C" of SPACED and commit.
await page.evaluate((rid: string) => {
const el = document.querySelector<HTMLDivElement>(
`[data-testid="pdf-editor-run-${rid}"]`,
)!;
el.focus();
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let node: Text | null = null;
while (walker.nextNode()) {
const t = walker.currentNode as Text;
if (t.data.includes("SPACED")) {
node = t;
break;
}
}
if (!node) throw new Error("heading text node not found");
const idx = node.data.indexOf("C");
const sel = window.getSelection()!;
const range = document.createRange();
range.setStart(node, idx);
range.setEnd(node, idx + 1);
sel.removeAllRanges();
sel.addRange(range);
document.execCommand("delete");
}, heading!.id);
await page.waitForTimeout(300);
await page.evaluate((rid: string) => {
document
.querySelector<HTMLElement>(`[data-testid="pdf-editor-run-${rid}"]`)
?.blur();
}, heading!.id);
await page.waitForTimeout(1500);
// Save + reopen the produced bytes.
const downloadPromise = page.waitForEvent("download");
await page.getByTestId("pdf-editor-download").click();
const dl = await downloadPromise;
const stream = await dl.createReadStream();
const chunks: Buffer[] = [];
for await (const c of stream) chunks.push(c as Buffer);
await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({
name: "round-trip.pdf",
mimeType: "application/pdf",
buffer: Buffer.concat(chunks),
});
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(800);
const after = await readRuns();
const headingAfter = after.find((r) =>
r.text.replace(/\s/g, "").includes("SPAED"),
);
const bodyAfter = after.find((r) => r.text.includes("Normal"));
expect(
headingAfter,
`edited heading present after reopen; runs=${JSON.stringify(after)}`,
).toBeTruthy();
// The re-emitted heading still carries ~2pt tracking (round-trips through
// the reader's inference on the fresh per-char objects).
expect(headingAfter!.charSpacingPt).toBeGreaterThan(1.2);
expect(headingAfter!.charSpacingPt).toBeLessThan(3.0);
// The untouched body line still reads as unspaced.
expect(bodyAfter?.charSpacingPt ?? 0).toBe(0);
expect(errs, `no page errors:\n${errs.join("\n")}`).toEqual([]);
});
@@ -0,0 +1,287 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import path from "path";
import type { EditorTestWindow } from "@app/tests/stubbed/editorTestTypes";
/**
* Layout coverage for the properties-inspector panel.
*
* The contract these tests pin down: the canvas strip carries only verbs that
* are always available, everything selection-scoped lives in the right-hand
* inspector, and set-and-forget preferences live behind the overflow menu.
*/
const SAMPLE = path.join(
import.meta.dirname,
"../../../../public/samples/Sample.pdf",
);
async function open(page: Page, firstPage = 0): Promise<void> {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 15_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(SAMPLE);
await expect(page.getByTestId(`pdf-editor-page-${firstPage}`)).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(900);
}
async function runId(
page: Page,
pageIdx: number,
src: string,
): Promise<string> {
const id = await page.evaluate(
({ pageIdx, src }: { pageIdx: number; src: string }) => {
const r = (window as unknown as EditorTestWindow).__editor_store.doc
.page(pageIdx)
.runs.find((x) => new RegExp(src).test(x.text));
return r ? r.id : null;
},
{ pageIdx, src },
);
if (!id) throw new Error(`run /${src}/ not found`);
return id;
}
async function selectOne(page: Page, id: string): Promise<void> {
await page.evaluate(
(rid: string) =>
(
window as unknown as EditorTestWindow
).__editor_store.selection.selectOne(rid),
id,
);
await page.waitForTimeout(150);
}
/** Switch to the Document tab, which owns the document-level preferences. */
async function openSettings(page: Page): Promise<void> {
await page.getByTestId("pdf-editor-tab-document").click();
await expect(page.getByTestId("pdf-editor-view-settings")).toBeVisible();
}
test.describe("PDF text editor - inspector layout", () => {
test("Arrange groups z-order, align and distribute with correct gating", async ({
page,
}) => {
await open(page, 0);
// A single single-line run: z-order is always available, align needs
// 2+ objects and distribute needs 3+, so both are gated off here.
const id = await runId(page, 0, "Downloads");
await selectOne(page, id);
const arrange = page.getByTestId("pdf-editor-arrange-menu");
await expect(arrange).toBeVisible();
await expect(arrange).toBeEnabled();
// Arrange is a toolbar verb, beside the formatting it accompanies.
await expect(
page
.getByTestId("pdf-editor-toolbar")
.getByTestId("pdf-editor-arrange-menu"),
).toHaveCount(1);
await arrange.click();
// Sub-section labels make the grouping explicit.
await expect(page.getByText("Align · needs 2+ objects")).toBeVisible();
await expect(page.getByText("Distribute · needs 3+ objects")).toBeVisible();
// Z-order works on a single object; align/distribute are disabled.
await expect(page.getByTestId("pdf-editor-z-to-front")).toBeEnabled();
await expect(page.getByTestId("pdf-editor-align-left")).toBeDisabled();
await expect(page.getByTestId("pdf-editor-distribute-h")).toBeDisabled();
await page.keyboard.press("Escape");
});
test("image controls stay absent while a text run is selected", async ({
page,
}) => {
await open(page, 0);
const id = await runId(page, 0, "Downloads");
await selectOne(page, id);
// Rotate/flip cannot apply to a text run, so the section does not render
// at all rather than rendering disabled.
await expect(page.getByTestId("pdf-editor-imgop-menu")).toHaveCount(0);
await expect(page.getByTestId("pdf-editor-imgop-rotate-cw")).toHaveCount(0);
// Text-only controls are the ones on show instead.
await expect(page.getByTestId("pdf-editor-font-size")).toBeVisible();
});
test("the inspector shows nothing selectable with nothing selected", async ({
page,
}) => {
await open(page, 0);
// No object picked: the panel offers an empty state, not a wall of
// disabled controls the user has to learn to ignore.
await expect(page.getByTestId("pdf-editor-nothing-selected")).toBeVisible();
for (const id of [
"pdf-editor-arrange-menu",
"pdf-editor-imgop-menu",
"pdf-editor-font-size",
"pdf-editor-toggle-lock",
"pdf-editor-delete",
"pdf-editor-group",
"pdf-editor-ungroup",
"pdf-editor-pos-x",
]) {
await expect(page.getByTestId(id)).toHaveCount(0);
}
});
test("lock and delete are icon buttons in the toolbar", async ({ page }) => {
await open(page, 0);
const id = await runId(page, 0, "Downloads");
await selectOne(page, id);
const toolbar = page.getByTestId("pdf-editor-toolbar");
// Icon-only: identified by aria-label, located inside the toolbar.
await expect(toolbar.getByTestId("pdf-editor-toggle-lock")).toBeVisible();
await expect(toolbar.getByTestId("pdf-editor-delete")).toBeVisible();
await expect(page.getByTestId("pdf-editor-toggle-lock")).toHaveAttribute(
"aria-label",
/lock selection/i,
);
});
test("the strip shows formatting only once something is selected", async ({
page,
}) => {
await open(page, 0);
const toolbar = page.getByTestId("pdf-editor-toolbar");
// Always available, selection or not.
for (const id of ["pdf-editor-undo", "pdf-editor-redo"]) {
await expect(toolbar.getByTestId(id)).toBeVisible();
}
// Contextual: absent, rather than present-and-greyed, with no selection.
for (const id of [
"pdf-editor-font-size",
"pdf-editor-colour",
"pdf-editor-italic",
"pdf-editor-arrange-menu",
"pdf-editor-toggle-lock",
"pdf-editor-delete",
]) {
await expect(toolbar.getByTestId(id)).toHaveCount(0);
}
// ...and all of them appear in the strip once a run is picked.
await selectOne(page, await runId(page, 0, "Downloads"));
for (const id of [
"pdf-editor-font-size",
"pdf-editor-colour",
"pdf-editor-italic",
"pdf-editor-arrange-menu",
"pdf-editor-toggle-lock",
"pdf-editor-delete",
]) {
await expect(toolbar.getByTestId(id)).toBeVisible();
}
});
test("the inspector keeps geometry and paragraph structure", async ({
page,
}) => {
await open(page, 0);
await selectOne(page, await runId(page, 0, "Downloads"));
const sidebar = page.getByTestId("pdf-editor-sidebar-status");
// Labelled numeric fields need the panel's vertical room, so they live
// here rather than in the horizontal strip.
for (const id of [
"pdf-editor-pos-x",
"pdf-editor-pos-y",
"pdf-editor-size-w",
"pdf-editor-group",
]) {
await expect(sidebar.getByTestId(id)).toBeVisible();
}
// ...and the strip does not duplicate them.
for (const id of ["pdf-editor-pos-x", "pdf-editor-group"]) {
await expect(
page.getByTestId("pdf-editor-toolbar").getByTestId(id),
).toHaveCount(0);
}
});
test("zoom floats on the canvas and save is pinned to the panel", async ({
page,
}) => {
await open(page, 0);
await expect(page.getByTestId("pdf-editor-save")).toBeVisible();
await expect(page.getByTestId("pdf-editor-download")).toBeVisible();
// Zoom sits over the pages it scales, not in the far rail.
const zoom = page.getByTestId("pdf-editor-zoom-controls");
await expect(zoom).toBeVisible();
await expect(zoom.getByTestId("pdf-editor-zoom-percent")).toBeVisible();
await expect(
page
.getByTestId("pdf-editor-stage")
.getByTestId("pdf-editor-zoom-controls"),
).toHaveCount(0);
});
test("everyday settings are plain; only parse options are behind Advanced", async ({
page,
}) => {
await open(page);
// Find and the shortcuts sheet are everyday controls, not settings: they
// sit in the panel header, one click from anywhere.
await expect(page.getByTestId("pdf-editor-open-find")).toBeVisible();
await expect(page.getByTestId("pdf-editor-help")).toBeVisible();
await openSettings(page);
// View toggles are on show - no disclosure to discover first.
await expect(page.getByTestId("pdf-editor-toggle-rulers")).toBeVisible();
await expect(page.getByTestId("pdf-editor-spellcheck")).toBeVisible();
// The two options that change how the document was PARSED start folded,
// because switching grouping re-reads it and drops undo history.
await expect(
page.getByTestId("pdf-editor-grouping-mode-control"),
).toBeHidden();
await expect(
page.getByTestId("pdf-editor-width-mode-control"),
).toBeHidden();
await page.getByTestId("pdf-editor-advanced-toggle").click();
await expect(
page.getByTestId("pdf-editor-grouping-mode-control"),
).toBeVisible();
await expect(
page.getByTestId("pdf-editor-width-mode-control"),
).toBeVisible();
});
test("Add text toggles its label and inserts from the panel", async ({
page,
}) => {
await open(page, 0);
const runs = page.locator('[data-testid^="pdf-editor-run-p0-"]');
const before = await runs.count();
const addText = page.getByTestId("pdf-editor-add-text");
await addText.click();
await expect(addText).toContainText(/click page/i);
await page
.getByTestId("pdf-editor-page-0")
.click({ position: { x: 200, y: 400 } });
await expect(runs).toHaveCount(before + 1, { timeout: 5_000 });
await expect(addText).toHaveText("Add text");
});
test("the Document tab lists the page fonts with a status badge", async ({
page,
}) => {
await open(page, 0);
await page.getByTestId("pdf-editor-tab-document").click();
const panel = page.getByTestId("pdf-editor-fonts-panel");
await expect(panel).toBeVisible();
// Collapsed by default: one headline row carrying an honest tone
// (ok / info / warn), never a blanket "no issues" for embedded fonts.
const compat = panel.getByTestId("pdf-editor-font-compat");
await expect(compat).toBeVisible();
const tone = await compat.getAttribute("data-compat");
expect(["ok", "info", "warn"]).toContain(tone);
// The per-font detail is one click away.
await panel.getByTestId("pdf-editor-fonts-toggle").click();
const badges = panel.locator('[data-testid^="pdf-editor-font-"]');
expect(await badges.count()).toBeGreaterThan(0);
});
});
@@ -0,0 +1,130 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import path from "path";
// `PdfiumTextReader.populate` mints fresh runs with fresh ids, so re-reading a
// page after a commit would invalidate every id the selection, the undo stack
// and React's keys hold. `PdfiumModelSync.resyncPage` re-reads and folds the
// result onto the EXISTING run objects by PDFium object pointer instead. These
// pin that identity actually survives - the property the whole approach rests on.
const SAMPLE_PDF = path.join(
import.meta.dirname,
"../test-fixtures/sample.pdf",
);
const PARAGRAPH_PDF = path.join(
import.meta.dirname,
"../test-fixtures/paragraph-sample.pdf",
);
interface SyncResult {
changed: boolean;
matched: number;
unmatched: number;
appeared: number;
}
async function open(page: import("@playwright/test").Page, file: string) {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 15_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(file);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(600);
}
const RUN_IDS = () =>
(
window as unknown as {
__editor_store: {
state: { pages: { runs: { id: string; text: string }[] }[] };
};
}
).__editor_store.state.pages[0].runs.map((r) => r.id);
const RESYNC = () =>
(
window as unknown as {
__editor_store: { resyncPage: (i: number) => SyncResult | null };
}
).__editor_store.resyncPage(0);
test.describe("PDF text editor - identity-preserving pdfium re-read", () => {
for (const [label, file] of [
["single-line runs", SAMPLE_PDF],
["paragraph runs", PARAGRAPH_PDF],
] as const) {
test(`re-reading ${label} matches every run and keeps its id`, async ({
page,
}) => {
await open(page, file);
const before = await page.evaluate(RUN_IDS);
expect(before.length).toBeGreaterThan(0);
const result = (await page.evaluate(RESYNC)) as SyncResult | null;
expect(result, "resyncPage should return a result").not.toBeNull();
// Every live run must be claimed by exactly one re-read run.
expect(result!.matched).toBe(before.length);
expect(result!.unmatched).toBe(0);
expect(result!.appeared).toBe(0);
const after = await page.evaluate(RUN_IDS);
expect(after, "run ids must survive a re-read").toEqual(before);
});
}
test("re-reading after an edit still matches the edited run by pointer", async ({
page,
}) => {
await open(page, SAMPLE_PDF);
const before = await page.evaluate(RUN_IDS);
const tid = `pdf-editor-run-${before[0]}`;
await page.evaluate((id) => {
const el = document.querySelector<HTMLDivElement>(
`[data-testid="${id}"]`,
);
if (!el) throw new Error("run missing");
el.focus();
const sel = window.getSelection();
if (!sel) throw new Error("no selection api");
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
document.execCommand("insertText", false, "ZZ");
}, tid);
await page.waitForTimeout(300);
const result = (await page.evaluate(RESYNC)) as SyncResult | null;
expect(result).not.toBeNull();
// The edit replaces objects, so pointer matching is what has to hold up.
expect(result!.unmatched).toBe(0);
expect(result!.matched).toBe(before.length);
const after = await page.evaluate(RUN_IDS);
expect(after, "ids must survive a re-read AFTER an edit").toEqual(before);
});
test("a re-read does not disturb the model's text", async ({ page }) => {
await open(page, SAMPLE_PDF);
const textOf = () =>
page.evaluate(() =>
(
window as unknown as {
__editor_store: {
state: { pages: { runs: { text: string }[] }[] };
};
}
).__editor_store.state.pages[0].runs.map((r) => r.text),
);
const before = await textOf();
await page.evaluate(RESYNC);
await page.waitForTimeout(200);
expect(await textOf()).toEqual(before);
});
});
@@ -0,0 +1,205 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import path from "path";
// Every toolbar action walks the selection and dispatches one command PER RUN.
// `deleteSelection` already knew that was wrong and wraps its commands in a
// CompositeCommand ("a 30-object delete must be a single undo step, not 30");
// the style actions never got the same treatment.
//
// It only became a visible bug once select-all reached the whole document and
// Ctrl+click could extend the selection again: restyle 200 runs and undo is
// 200 presses behind, so the first Ctrl+Z looks like the change had only
// landed on part of the document.
//
// SetFontFamilyCommand - no coalesceKey at all, so N runs = N undo entries.
// SetFontSizeCommand - keys on `${pageIndex}:${runId}`, so it can never
// coalesce ACROSS runs either.
//
// SetColourCommand and SetTextOutlineCommand use run-independent keys, so they
// already collapse into one step - which is what the fixed ones must match.
const MANY_PAGES_PDF = path.join(
import.meta.dirname,
"../test-fixtures/many-pages-sample.pdf",
);
async function openEditor(page: import("@playwright/test").Page, file: string) {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(file);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 60_000,
});
await page.waitForTimeout(1500);
}
interface RunView {
id: string;
pageIndex: number;
fontSize: number;
fontId: string;
}
function runsInModel(
page: import("@playwright/test").Page,
): Promise<RunView[]> {
return page.evaluate(() => {
const w = window as unknown as {
__editor_store: {
state: {
pages: {
pageIndex: number;
runs: { id: string; fontSize: number; fontId: string }[];
}[];
};
};
};
return w.__editor_store.state.pages.flatMap((p) =>
p.runs.map((r) => ({
id: r.id,
pageIndex: p.pageIndex,
fontSize: r.fontSize,
fontId: r.fontId,
})),
);
});
}
/** Undo entries currently on the stack. */
function undoDepth(page: import("@playwright/test").Page): Promise<number> {
return page.evaluate(
() =>
(
window as unknown as {
__editor_store: {
history: { size(): { undo: number; redo: number } };
};
}
).__editor_store.history.size().undo,
);
}
async function selectAll(page: import("@playwright/test").Page) {
await page.keyboard.press("Control+a");
await page.waitForTimeout(800);
const selected = await page.evaluate(
() =>
(
window as unknown as {
__editor_store: { selection: { value: { runIds: string[] } } };
}
).__editor_store.selection.value.runIds.length,
);
// The whole point is a MULTI-run selection; a one-run fixture proves nothing.
expect(
selected,
"fixture must give select-all more than one run",
).toBeGreaterThan(1);
return selected;
}
test.describe("PDF text editor - a restyle of many runs is one undo step", () => {
test("changing the font family over a select-all undoes in one press", async ({
page,
}) => {
await openEditor(page, MANY_PAGES_PDF);
// After select-all, not before: selecting is what reads the pages past the
// eager window into the model, so an earlier snapshot would not know them.
const selected = await selectAll(page);
const before = await runsInModel(page);
const familyBefore = new Map(before.map((r) => [r.id, r.fontId]));
const depthBefore = await undoDepth(page);
const picker = page.getByTestId("pdf-editor-font-family");
await picker.click();
await page.getByRole("option", { name: "Helvetica", exact: true }).click();
await page.waitForTimeout(2500);
// Sanity: the change has to have landed, or the undo assertion is vacuous.
const changed = await runsInModel(page);
expect(
changed.filter((r) => /helvetica/i.test(r.fontId)).length,
"the font change did not apply",
).toBeGreaterThan(1);
const steps = (await undoDepth(page)) - depthBefore;
expect(
steps,
`one toolbar click on ${selected} runs pushed ${steps} undo entries`,
).toBe(1);
await page.getByTestId("pdf-editor-undo").click();
await page.waitForTimeout(2000);
const after = await runsInModel(page);
const stuck = after.filter((r) => r.fontId !== familyBefore.get(r.id));
expect(
stuck.map((r) => `p${r.pageIndex}:${r.id}=${r.fontId}`),
"one undo must put every run back, not just the last one touched",
).toEqual([]);
});
test("changing the font size over a select-all undoes in one press", async ({
page,
}) => {
await openEditor(page, MANY_PAGES_PDF);
const selected = await selectAll(page);
const before = await runsInModel(page);
const sizeBefore = new Map(before.map((r) => [r.id, r.fontSize]));
const depthBefore = await undoDepth(page);
// fill() sets the value in one shot, so this is a single user edit and
// any extra undo entries come from the per-run dispatch, not from typing.
const sizeInput = page.getByTestId("pdf-editor-font-size");
await sizeInput.fill("33");
await sizeInput.blur();
await page.waitForTimeout(2000);
const changed = await runsInModel(page);
expect(
changed.filter((r) => Math.abs(r.fontSize - 33) < 0.5).length,
"the size change did not apply",
).toBeGreaterThan(1);
const steps = (await undoDepth(page)) - depthBefore;
expect(
steps,
`one size change over ${selected} runs pushed ${steps} undo entries`,
).toBe(1);
await page.getByTestId("pdf-editor-undo").click();
await page.waitForTimeout(2000);
const after = await runsInModel(page);
const stuck = after.filter(
(r) => Math.abs(r.fontSize - (sizeBefore.get(r.id) ?? -1)) > 0.5,
);
expect(
stuck.map((r) => `p${r.pageIndex}:${r.id}@${r.fontSize}`),
"one undo must restore every run's size",
).toEqual([]);
});
test("recolouring a select-all already undoes in one press", async ({
page,
}) => {
// The control case: SetColourCommand's key ignores the run, so this path
// was never broken. It pins the behaviour the other two must match.
await openEditor(page, MANY_PAGES_PDF);
await selectAll(page);
const depthBefore = await undoDepth(page);
await page.getByTestId("pdf-editor-colour").fill("#c02020"); // theme-allow-color test input for the picker, not a UI colour
await page.getByTestId("pdf-editor-colour").blur();
await page.waitForTimeout(2000);
const steps = (await undoDepth(page)) - depthBefore;
expect(steps, `recolour pushed ${steps} undo entries`).toBe(1);
});
});
@@ -0,0 +1,121 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Route } from "@playwright/test";
import path from "path";
import type { EditorTestWindow } from "@app/tests/stubbed/editorTestTypes";
/** Regression for the mushroom-life.pdf "paragraph scramble" report. */
const MUSHROOM = path.join(
import.meta.dirname,
"../test-fixtures/mushroom-life.pdf",
);
test("mid-line paragraph edit does not scramble unchanged words (cold backend, non-subset font)", async ({
page,
}) => {
test.setTimeout(120_000);
// Offline backend: every encode-charcodes call fails, so the emit path hits
// the cold-cache fallback - the exact condition that used to scramble.
await page.route("**/encode-charcodes", (route: Route) => route.abort());
const errs: string[] = [];
page.on("pageerror", (e) => errs.push(e.message));
await page.goto("/pdf-text-editor?charcodeStrategy=backend", {
waitUntil: "domcontentloaded",
});
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(MUSHROOM);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(1200);
const probe = () =>
page.evaluate(() => {
const s = (window as unknown as EditorTestWindow).__editor_store;
const pg = s.doc.page(0);
const r =
pg.runs.find((x) => (x.paragraphLineSlots?.length ?? 0) > 1) ??
pg.runs[0];
return {
id: r?.id as string,
fontSubset: r?.fontSubset as boolean,
lineCount: r?.paragraphLineSlots?.length ?? 0,
firstLine: (r?.text ?? "").split("\n")[0] as string,
};
});
const before = await probe();
// Sanity: a multi-line paragraph in a non-subset font (the scramble setup).
expect(before.lineCount).toBeGreaterThan(1);
expect(before.fontSubset).toBe(false);
expect(before.firstLine).toContain("fascinating");
const id = before.id;
// Mid-line replace spanning several words (so it spans spaces) -> forces the
// whole one-object line to be re-emitted, the path that used to scramble.
await page.locator(`[data-testid="pdf-editor-run-${id}"]`).click();
await page.waitForTimeout(400);
await page.evaluate((rid) => {
const el = document.querySelector<HTMLDivElement>(
`[data-testid="pdf-editor-run-${rid}"]`,
);
if (!el) throw new Error("overlay missing");
el.focus();
// The overlay may render words in boxes, so the editable's first
// child is not necessarily the text node holding character N.
const at = (offset: number): { node: Node; offset: number } => {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let seen = 0;
let node = walker.nextNode();
while (node) {
const len = (node.textContent ?? "").length;
if (seen + len >= offset) return { node, offset: offset - seen };
seen += len;
node = walker.nextNode();
}
return { node: el, offset: 0 };
};
const len = (el.textContent ?? "").length;
const sel = window.getSelection()!;
const range = document.createRange();
const start = at(Math.min(5, len));
const end = at(Math.min(20, len)); // "ooms represent " - has spaces
range.setStart(start.node, start.offset);
range.setEnd(end.node, end.offset);
sel.removeAllRanges();
sel.addRange(range);
document.execCommand("insertText", false, "X");
}, id);
await page.waitForTimeout(250);
await page.evaluate(
(rid) =>
document
.querySelector<HTMLElement>(`[data-testid="pdf-editor-run-${rid}"]`)
?.blur(),
id,
);
await page.waitForTimeout(1200);
// The model text reflects the RENDERED glyphs after the blur reflow re-reads
// the page.
const after = await probe();
const firstLine = after.firstLine;
expect(errs, `no page errors:\n${errs.join("\n")}`).toEqual([]);
// Words AFTER the edited span are unchanged and must render correctly - the
// exact words the content-stream guess used to scramble.
for (const word of ["fascinating", "organisms", "occupying", "unique"]) {
expect(
firstLine,
`unchanged word "${word}" must survive the re-emit (no scramble). Got: ${JSON.stringify(firstLine)}`,
).toContain(word);
}
// And the edit itself applied (the replaced span collapsed to "X").
expect(firstLine).toContain("MushrX");
});
@@ -0,0 +1,164 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Route } from "@playwright/test";
import path from "path";
// Regression for the mushroom-life.pdf reports (backend charcode strategy): 1.
const MUSHROOM = path.join(
import.meta.dirname,
"../test-fixtures/mushroom-life.pdf",
);
interface CharcodeEvent {
text: string;
outcome: string;
resolved: number[];
}
test("mushroom first-line edits never reuse whitespace (no „) and keep the paragraph lines", async ({
page,
}) => {
// BUGGY backend: returns a charcode for every code point, even whitespace
// (space -> 0x20). The frontend must still refuse to reuse it.
await page.route("**/encode-charcodes", (route: Route) => {
let text = "";
try {
text = (route.request().postDataJSON() as { text?: string }).text ?? "";
} catch {
/* ignore */
}
const charcodes = Array.from(text).map((ch) => ch.codePointAt(0) ?? 0);
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ charcodes, missing: [] }),
});
});
const errs: string[] = [];
page.on("pageerror", (e) => errs.push(e.message));
await page.goto("/pdf-text-editor?charcodeStrategy=backend", {
waitUntil: "domcontentloaded",
});
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(MUSHROOM);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(1000);
const probe = () =>
page.evaluate(() => {
const s = (
window as unknown as {
__editor_store: {
doc: {
page: (i: number) => {
runs: Array<{
id: string;
text: string;
paragraphLineSlots?: unknown[];
}>;
};
};
};
}
).__editor_store;
const pg = s.doc.page(0);
const r =
pg.runs.find((x) => (x.paragraphLineSlots?.length ?? 0) > 1) ??
pg.runs[0];
return {
id: r?.id,
lineCount: r?.paragraphLineSlots?.length ?? 0,
text: (r?.text ?? "").slice(0, 200),
hasLowQuote: (r?.text ?? "").includes("„"),
};
});
const before = await probe();
expect(before.lineCount).toBeGreaterThan(1);
const id = before.id;
// Focus engages the backend prewarm; wait for it to complete so the cache
// is populated before the first keystroke.
const prewarm = page.waitForEvent("console", {
predicate: (m) => /\[charcode\] backend prewarm pageIdx=/.test(m.text()),
timeout: 30_000,
});
await page.locator(`[data-testid="pdf-editor-run-${id}"]`).click();
await prewarm.catch(() => undefined);
// MID-LINE replace: select a span that spans several words (so it includes
// spaces) and replace it.
await page.evaluate((rid) => {
const el = document.querySelector<HTMLDivElement>(
`[data-testid="pdf-editor-run-${rid}"]`,
);
if (!el) return;
el.focus();
// The overlay may render words in boxes, so the editable's first
// child is not necessarily the text node holding character N.
const at = (offset: number): { node: Node; offset: number } => {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let seen = 0;
let node = walker.nextNode();
while (node) {
const len = (node.textContent ?? "").length;
if (seen + len >= offset) return { node, offset: offset - seen };
seen += len;
node = walker.nextNode();
}
return { node: el, offset: 0 };
};
const len = (el.textContent ?? "").length;
const sel = window.getSelection()!;
const range = document.createRange();
const start = at(Math.min(5, len));
const end = at(Math.min(20, len)); // "ooms represent " - has spaces
range.setStart(start.node, start.offset);
range.setEnd(end.node, end.offset);
sel.removeAllRanges();
sel.addRange(range);
document.execCommand("insertText", false, "X");
}, id);
await page.waitForTimeout(200);
await page.evaluate(
(rid) =>
document
.querySelector<HTMLElement>(`[data-testid="pdf-editor-run-${rid}"]`)
?.blur(),
id,
);
await page.waitForTimeout(1000);
const after = await probe();
expect(errs, `no page errors:\n${errs.join("\n")}`).toEqual([]);
// No „ in the model text - spaces survived as real gaps, not the
// quotedblbase glyph at subset code 0x20.
expect(after.hasLowQuote).toBe(false);
// Paragraph kept its lines - no collapse onto a single baseline.
expect(after.lineCount).toBeGreaterThan(1);
// Decisive: no emit event ever resolved MORE charcodes than it had
// non-whitespace chars.
const events: CharcodeEvent[] = await page.evaluate(
() =>
(window as unknown as { __charcode_events?: CharcodeEvent[] })
.__charcode_events ?? [],
);
const whitespaceReused = events.filter((e) => {
const nonWs = Array.from(e.text).filter((c) => !/\s/.test(c)).length;
return (e.resolved?.length ?? 0) > nonWs;
});
expect(
whitespaceReused,
`whitespace must never be charcode-reused. Offending:\n${JSON.stringify(whitespaceReused, null, 2)}`,
).toHaveLength(0);
});
@@ -0,0 +1,168 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import path from "path";
// The editable overlay has to stay in register with the page bitmap, line for
// line. A painted line block IS one line of the PDF - one text object at one
// pen origin - and the page has no such thing as a soft break.
//
// The blocks were changed to `min-height` and `white-space: inherit`, so when
// the container flipped to `pre-wrap` (which it did merely by growing to the
// page-edge cap) a long line took TWO rows in the overlay and one on the page.
// Every block below it was pushed a full line-height down while the ink stayed
// put: the box overhung its own text by a row, overlapping what sat beneath it,
// and the last line's rendered text appeared to be stuck on the previous line.
// Measured on Sample.pdf at the time: box 163.8px tall over 131.7px of rendered
// text, a 30.9px offset against a 32.1px line-height - exactly one line.
//
// The user reported it as "new lines can make the text invisible, text overlaps
// the textbox but rendered text stays on previous line".
// Long enough to drive the box hard into its page-edge cap, which is what
// flipped the container to pre-wrap and took the painted blocks with it.
const LONG_LINE =
"the quick brown fox jumps over the lazy dog and keeps running well past the right hand edge of the page and then some more words after that too";
const SAMPLE = path.join(
import.meta.dirname,
"../../../../public/samples/Sample.pdf",
);
async function open(page: Page): Promise<void> {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(SAMPLE);
await expect(page.getByTestId("pdf-editor-page-1")).toBeVisible({
timeout: 60_000,
});
await page.waitForTimeout(1500);
}
interface StoreView {
state: { pages: { runs: { id: string; text: string }[] }[] };
}
async function findRun(page: Page): Promise<string> {
const id = await page.evaluate(() => {
const s = (window as unknown as { __editor_store: StoreView })
.__editor_store;
for (const p of s.state.pages) {
for (const r of p.runs) {
if (/Stirling\s+PDF\s+is\s+a\s+robust/.test(r.text)) return r.id;
}
}
return "";
});
expect(id, "fixture paragraph not found").not.toBe("");
return id;
}
/** Rows each painted line block occupies, and the block/model line counts. */
function register(page: Page, runId: string) {
return page.evaluate((rid: string) => {
const el = document.querySelector<HTMLElement>(
`[data-testid="pdf-editor-run-${rid}"]`,
);
if (!el) return null;
const blocks = [
...el.querySelectorAll<HTMLElement>("[data-pdf-editor-line]"),
];
const rows = blocks.map((b) => {
const lh = parseFloat(getComputedStyle(b).lineHeight);
return lh > 0 ? Math.round(b.getBoundingClientRect().height / lh) : 1;
});
const s = (
window as unknown as {
__editor_store: {
state: { pages: { runs: { id: string; text: string }[] }[] };
};
}
).__editor_store;
let modelLines = 0;
for (const p of s.state.pages) {
const r = p.runs.find((x) => x.id === rid);
if (r) modelLines = r.text.split("\n").length;
}
return {
rows,
blocks: blocks.length,
modelLines,
blockWhiteSpace: blocks.map((b) => getComputedStyle(b).whiteSpace),
boxHeight: +el.getBoundingClientRect().height.toFixed(1),
contentHeight: +blocks
.reduce((n, b) => n + b.getBoundingClientRect().height, 0)
.toFixed(1),
};
}, runId);
}
test.describe("PDF text editor - the overlay stays in register with the page", () => {
test("a painted line never occupies more than one row", async ({ page }) => {
// The 140-char burst is real typing work; CI WebKit needs more than the
// default budget for it (same allowance the width-mode specs take).
test.setTimeout(180_000);
await open(page);
const runId = await findRun(page);
const run = page.locator(`[data-testid="pdf-editor-run-${runId}"]`);
await run.click();
await page.waitForTimeout(400);
// Enter, then a line long enough to reach the page-edge cap - which is what
// used to flip the container to pre-wrap and take the blocks with it.
await page.keyboard.press("End");
await page.waitForTimeout(200);
await page.keyboard.press("Enter");
await page.waitForTimeout(900);
await page.keyboard.type(LONG_LINE, { delay: 12 });
await page.waitForTimeout(1500);
const reg = await register(page, runId);
expect(reg, "run vanished").not.toBeNull();
// Guard: with no painted blocks the row assertion below is vacuous.
expect(
reg!.blocks,
"the run should be painted as line blocks, not plain text",
).toBeGreaterThan(1);
expect(
reg!.blockWhiteSpace.every((w) => w === "pre"),
`a painted block was allowed to wrap: ${reg!.blockWhiteSpace.join(", ")}`,
).toBe(true);
expect(
reg!.rows,
`a painted line took more than one row: ${JSON.stringify(reg!.rows)}`,
).toEqual(reg!.rows.map(() => 1));
});
test("the overlay shows exactly as many rows as the model has lines", async ({
page,
}) => {
test.setTimeout(180_000);
await open(page);
const runId = await findRun(page);
const run = page.locator(`[data-testid="pdf-editor-run-${runId}"]`);
await run.click();
await page.waitForTimeout(400);
await page.keyboard.press("End");
await page.waitForTimeout(200);
await page.keyboard.press("Enter");
await page.waitForTimeout(900);
await page.keyboard.type(LONG_LINE, { delay: 12 });
await page.waitForTimeout(1500);
const reg = await register(page, runId);
expect(reg).not.toBeNull();
expect(reg!.blocks).toBeGreaterThan(1);
// The register invariant: the page draws one row per model line, so the
// overlay must show exactly that many. A block that wrapped added a row the
// page has no counterpart for, and everything below it lost alignment.
const totalRows = reg!.rows.reduce((n, r) => n + r, 0);
expect(
totalRows,
`overlay shows ${totalRows} rows for ${reg!.modelLines} model lines (rows: ${JSON.stringify(reg!.rows)})`,
).toBe(reg!.modelLines);
});
});
@@ -0,0 +1,707 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import path from "path";
import type { EditorTestWindow } from "@app/tests/stubbed/editorTestTypes";
const SAMPLE = path.join(
import.meta.dirname,
"../../../../public/samples/Sample.pdf",
);
/** Comprehensive paragraph-editing battery for the PDF text editor. */
async function gotoWrap(page: Page): Promise<void> {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 15_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(SAMPLE);
await expect(page.getByTestId("pdf-editor-page-1")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(700);
await page.getByTestId("pdf-editor-tab-document").click();
await page.getByTestId("pdf-editor-advanced-toggle").click();
await page
.getByTestId("pdf-editor-width-mode-control")
.getByText("Wrap", { exact: true })
.click();
await page.getByTestId("pdf-editor-tab-selected").click();
await page.waitForTimeout(150);
}
/** Load page 2 but leave the default "grow" width mode (no wrap toggle). */
async function gotoGrow(page: Page): Promise<void> {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 15_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(SAMPLE);
await expect(page.getByTestId("pdf-editor-page-1")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(700);
}
async function findPara(page: Page, re: RegExp): Promise<string | null> {
return page.evaluate((src: string) => {
const store = (window as unknown as EditorTestWindow).__editor_store;
const rx = new RegExp(src);
const r = store.doc.page(1).runs.find((x) => rx.test(x.text));
return r ? r.id : null;
}, re.source);
}
async function focusCaretEnd(page: Page, id: string): Promise<void> {
await page.evaluate((rid: string) => {
const el = document.querySelector<HTMLDivElement>(
`[data-testid="pdf-editor-run-${rid}"]`,
);
if (!el) throw new Error("run not in DOM");
el.focus();
const sel = window.getSelection()!;
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
}, id);
}
/** Type a string one character at a time (the way a real user does). */
async function typeChars(page: Page, id: string, str: string): Promise<void> {
await focusCaretEnd(page, id);
for (const ch of str) {
await page.evaluate((c: string) => {
document.execCommand("insertText", false, c);
}, ch);
await page.waitForTimeout(20);
}
}
async function blurRun(page: Page, id: string): Promise<void> {
await page.evaluate((rid: string) => {
document
.querySelector<HTMLElement>(`[data-testid="pdf-editor-run-${rid}"]`)
?.blur();
}, id);
await page.waitForTimeout(300);
}
interface Glyph {
text: string;
x: number;
right: number;
baseline: number;
}
interface ParaInfo {
text: string;
fontId: string;
fontSize: number;
pageWidth: number;
glyphs: Glyph[];
}
/** Read every leaf glyph's real text + bounds + baseline from PDFium. */
async function readGlyphs(page: Page, id: string): Promise<ParaInfo | null> {
return page.evaluate((rid: string) => {
const store = (window as unknown as EditorTestWindow).__editor_store;
const m = store.doc.module;
const pg = store.doc.page(1);
const r = pg.runs.find((x) => x.id === rid);
if (!r) return null;
const ptrs: number[] =
r.paragraphLeafPtrs && r.paragraphLeafPtrs.length
? r.paragraphLeafPtrs
: r.mergedFromPtrs;
const tp = m.FPDFText_LoadPage(pg.pagePtr);
const glyphs: Glyph[] = [];
try {
for (const ptr of ptrs) {
if (!ptr) continue;
const l = m.pdfium.wasmExports.malloc(4);
const b = m.pdfium.wasmExports.malloc(4);
const rr = m.pdfium.wasmExports.malloc(4);
const t = m.pdfium.wasmExports.malloc(4);
const mb = m.pdfium.wasmExports.malloc(24);
try {
if (!m.FPDFPageObj_GetBounds(ptr, l, b, rr, t)) continue;
const x = m.pdfium.getValue(l, "float");
const right = m.pdfium.getValue(rr, "float");
let baseline = m.pdfium.getValue(b, "float");
if (m.FPDFPageObj_GetMatrix(ptr, mb)) {
baseline = m.pdfium.getValue(mb + 20, "float");
}
const len = m.FPDFTextObj_GetText(ptr, tp, 0, 0);
let text = "";
if (len > 2) {
const buf = m.pdfium.wasmExports.malloc(len);
m.FPDFTextObj_GetText(ptr, tp, buf, len);
for (let i = 0; i < len - 2; i += 2) {
const code = m.pdfium.getValue(buf + i, "i16") & 0xffff;
if (code) text += String.fromCharCode(code);
}
m.pdfium.wasmExports.free(buf);
}
glyphs.push({ text, x, right, baseline });
} finally {
m.pdfium.wasmExports.free(l);
m.pdfium.wasmExports.free(b);
m.pdfium.wasmExports.free(rr);
m.pdfium.wasmExports.free(t);
m.pdfium.wasmExports.free(mb);
}
}
} finally {
m.FPDFText_ClosePage(tp);
}
return {
text: r.text,
fontId: r.fontId,
fontSize: r.fontSize,
pageWidth: pg.width,
glyphs,
};
}, id);
}
/** Number of distinct baselines (visual lines) the glyphs occupy. */
function lineCount(info: ParaInfo): number {
const ys = info.glyphs.map((g) => Math.round(g.baseline));
const uniq = new Set<number>();
for (const y of ys) {
let found = false;
for (const u of uniq) if (Math.abs(u - y) <= 2) found = true;
if (!found) uniq.add(y);
}
return uniq.size;
}
const INTRO = "Stirling\\s+PDF\\s+is\\s+a\\s+robust";
const CARD = "Comprehensive\\s+toolkit";
test.describe("PDF text editor - paragraph editing battery", () => {
test("wrap: a long appended tail wraps within the page after click-off", async ({
page,
}) => {
await gotoWrap(page);
const id = await findPara(page, new RegExp(INTRO));
if (!id) {
test.skip(true, "intro paragraph missing");
return;
}
await typeChars(
page,
id,
" ZZZZ YYYY XXXX WWWW VVVV UUUU TTTT SSSS RRRR QQQQ PPPP OOOO",
);
await blurRun(page, id);
const info = await readGlyphs(page, id);
if (!info) throw new Error("vanished");
const maxRight = Math.max(...info.glyphs.map((g) => g.right));
expect(
maxRight,
`text ran off the page (maxRight=${maxRight}, pageWidth=${info.pageWidth})`,
).toBeLessThanOrEqual(info.pageWidth);
expect(lineCount(info)).toBeGreaterThan(1);
});
test("wrap: typed text stays within the page while typing AND after click-off", async ({
page,
}) => {
await gotoWrap(page);
const id = await findPara(page, new RegExp(INTRO));
if (!id) {
test.skip(true, "intro paragraph missing");
return;
}
await typeChars(
page,
id,
" ZZZZ YYYY XXXX WWWW VVVV UUUU TTTT SSSS RRRR QQQQ PPPP OOOO",
);
// NO blur - the box the user SEES while editing must stay within the page.
await page.waitForTimeout(120);
const box = await page.evaluate((rid: string) => {
const el = document.querySelector<HTMLElement>(
`[data-testid="pdf-editor-run-${rid}"]`,
);
const pageEl = document.querySelector<HTMLElement>(
'[data-testid="pdf-editor-page-1"]',
);
if (!el || !pageEl) return null;
const er = el.getBoundingClientRect();
const pr = pageEl.getBoundingClientRect();
return { elRight: er.right, pageRight: pr.right };
}, id);
expect(box, "elements missing").not.toBeNull();
expect(
box!.elRight,
`editing box overflowed the page while typing (boxRight=${box!.elRight}, pageRight=${box!.pageRight})`,
).toBeLessThanOrEqual(box!.pageRight + 4);
// After click-off the baked glyphs stay on the page too.
await blurRun(page, id);
const info = await readGlyphs(page, id);
if (!info) throw new Error("vanished");
const maxRight = Math.max(...info.glyphs.map((g) => g.right));
expect(
maxRight,
`text overflowed the page after click-off (maxRight=${maxRight}, pageWidth=${info.pageWidth})`,
).toBeLessThanOrEqual(info.pageWidth + 2);
});
test("wrap: a manual Enter break is kept after click-off (no extra typing)", async ({
page,
}) => {
await gotoWrap(page);
const id = await findPara(page, new RegExp(INTRO));
if (!id) {
test.skip(true, "intro paragraph missing");
return;
}
// Append a clear marker, Enter, second marker.
await typeChars(page, id, " AAAALPHA");
await page.evaluate(() => {
document.execCommand("insertText", false, "\n");
});
await page.waitForTimeout(40);
await typeChars(page, id, "BBBBETA");
await blurRun(page, id);
const info = await readGlyphs(page, id);
if (!info) throw new Error("vanished");
// The two markers must sit on DIFFERENT baselines (a real break).
const a = info.glyphs.find((g) => g.text.includes("A"));
const baseOfAlpha = lastBaselineOfWord(info, "ALPHA");
const baseOfBeta = firstBaselineOfWord(info, "BBBB");
expect(a, "no glyphs").toBeTruthy();
expect(
baseOfAlpha !== null && baseOfBeta !== null,
`markers missing: ${JSON.stringify(info.text)}`,
).toBe(true);
expect(
baseOfBeta!,
`manual break lost: ALPHA baseline=${baseOfAlpha}, BETA baseline=${baseOfBeta}`,
).toBeLessThan(baseOfAlpha! - 1);
});
test("wrap: a manual Enter break survives a subsequent word-wrap", async ({
page,
}) => {
await gotoWrap(page);
const id = await findPara(page, new RegExp(INTRO));
if (!id) {
test.skip(true, "intro paragraph missing");
return;
}
// Manual break, then type a LONG tail that forces wrapping. The break
// between the original text and "GAMMA..." must remain a break.
await page.evaluate(() => {
document.execCommand("insertText", false, "");
});
await focusCaretEnd(page, id);
await page.evaluate(() => {
document.execCommand("insertText", false, "\n");
});
await page.waitForTimeout(40);
await typeChars(
page,
id,
"GAMMA DELTA EPSILON ZETA ETA THETA IOTA KAPPA LAMBDA MUMU NUNU",
);
await blurRun(page, id);
const info = await readGlyphs(page, id);
if (!info) throw new Error("vanished");
// "more." ends the original last line; "GAMMA" begins the manual line.
const baseMore = lastBaselineOfWord(info, "more");
const baseGamma = firstBaselineOfWord(info, "GAMMA");
expect(
baseMore !== null && baseGamma !== null,
`markers missing: ${JSON.stringify(info.text.slice(-80))}`,
).toBe(true);
expect(
baseGamma!,
`manual break merged by wrap: more=${baseMore}, GAMMA=${baseGamma}`,
).toBeLessThan(baseMore! - 1);
});
test("wrap: Enter at end then typing puts the new text on a lower line", async ({
page,
}) => {
await gotoWrap(page);
const id = await findPara(page, new RegExp(CARD));
if (!id) {
test.skip(true, "card paragraph missing");
return;
}
await focusCaretEnd(page, id);
await page.evaluate(() => document.execCommand("insertText", false, "\n"));
await page.waitForTimeout(40);
await typeChars(page, id, "NEWLINEWORD");
await blurRun(page, id);
const info = await readGlyphs(page, id);
if (!info) throw new Error("vanished");
const baseProc = lastBaselineOfWord(info, "processing");
const baseNew = firstBaselineOfWord(info, "NEWLINEWORD");
expect(baseNew !== null, `NEW word missing: ${info.text}`).toBe(true);
if (baseProc !== null) {
expect(baseNew!).toBeLessThan(baseProc! - 1);
}
});
test("integrity: every original word survives editing exactly once", async ({
page,
}) => {
await gotoWrap(page);
const id = await findPara(page, new RegExp(CARD));
if (!id) {
test.skip(true, "card paragraph missing");
return;
}
await typeChars(page, id, " APPENDIX");
await blurRun(page, id);
const info = await readGlyphs(page, id);
if (!info) throw new Error("vanished");
const flat = info.text.replace(/\s+/g, " ");
for (const w of ["Comprehensive", "toolkit", "processing", "APPENDIX"]) {
const n = flat.split(w).length - 1;
expect(
n,
`"${w}" appears ${n}x (expected 1): ${JSON.stringify(flat)}`,
).toBe(1);
}
});
test("integrity: typing into the MIDDLE of a paragraph keeps it intact", async ({
page,
}) => {
await gotoWrap(page);
const id = await findPara(page, new RegExp(CARD));
if (!id) {
test.skip(true, "card paragraph missing");
return;
}
// Caret after "Comprehensive", type a marker.
await page.evaluate((rid: string) => {
const el = document.querySelector<HTMLDivElement>(
`[data-testid="pdf-editor-run-${rid}"]`,
)!;
el.focus();
const tw = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let node: Text | null = null;
let rem = "Comprehensive".length;
while (tw.nextNode()) {
const n = tw.currentNode as Text;
const len = n.textContent?.length ?? 0;
if (rem <= len) {
node = n;
break;
}
rem -= len;
}
if (!node) return;
const sel = window.getSelection()!;
const range = document.createRange();
range.setStart(node, rem);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
document.execCommand("insertText", false, "MIDWORD");
}, id);
await page.waitForTimeout(80);
await blurRun(page, id);
const info = await readGlyphs(page, id);
if (!info) throw new Error("vanished");
expect(info.text.replace(/\s+/g, " ")).toContain("MIDWORD");
expect(info.text.replace(/\s+/g, " ")).toMatch(/Comprehensive.*toolkit/);
});
test("undo: wrapping then Ctrl+Z restores the original text and layout", async ({
page,
}) => {
await gotoWrap(page);
const id = await findPara(page, new RegExp(INTRO));
if (!id) {
test.skip(true, "intro paragraph missing");
return;
}
const before = await readGlyphs(page, id);
if (!before) throw new Error("vanished");
const beforeLines = lineCount(before);
await typeChars(
page,
id,
" OMEGA SIGMA PSICHI TAUTAU RHORHO PHIPHI UPSILON",
);
await blurRun(page, id);
// Undo until the edit is actually gone. A fixed press count with a fixed
// gap drops presses under load, and then the edit survives.
await expect
.poll(
async () => {
await page.keyboard.press("Control+z");
const now = await readGlyphs(page, id);
return now?.text.replace(/\s+/g, " ") ?? "";
},
{ timeout: 30_000, intervals: [50] },
)
.not.toContain("OMEGA");
const after = await readGlyphs(page, id);
if (!after) throw new Error("vanished after undo");
expect(after.text.replace(/\s+/g, " ")).toMatch(/Stirling.*robust/);
expect(Math.abs(lineCount(after) - beforeLines)).toBeLessThanOrEqual(1);
});
test("grow mode: typing a long tail grows right (one line, no wrap)", async ({
page,
}) => {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 15_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(SAMPLE);
await expect(page.getByTestId("pdf-editor-page-1")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(700);
// Default mode is "grow".
const id = await findPara(page, new RegExp(CARD));
if (!id) {
test.skip(true, "card paragraph missing");
return;
}
await typeChars(page, id, " GROWGROWGROW");
await blurRun(page, id);
const info = await readGlyphs(page, id);
if (!info) throw new Error("vanished");
expect(info.text.replace(/\s+/g, " ")).toContain("GROWGROWGROW");
});
test("wrap: a single very long unbreakable word does not corrupt the paragraph", async ({
page,
}) => {
await gotoWrap(page);
const id = await findPara(page, new RegExp(CARD));
if (!id) {
test.skip(true, "card paragraph missing");
return;
}
await typeChars(
page,
id,
" SUPERCALIFRAGILISTICEXPIALIDOCIOUSANTIDISESTAB",
);
await blurRun(page, id);
const info = await readGlyphs(page, id);
if (!info) throw new Error("vanished");
expect(info.text.replace(/\s+/g, " ")).toContain(
"SUPERCALIFRAGILISTICEXPIALIDOCIOUSANTIDISESTAB",
);
// Original words still intact.
expect(info.text.replace(/\s+/g, " ")).toMatch(/Comprehensive.*toolkit/);
});
test("wrap: a MID-paragraph manual break is not removed when a later line wraps", async ({
page,
}) => {
await gotoWrap(page);
const id = await findPara(page, new RegExp(CARD));
if (!id) {
test.skip(true, "card paragraph missing");
return;
}
// Insert a manual break right after "Comprehensive" (where the line is
// NOT full - width alone would keep "Comprehensive toolkit" together).
await page.evaluate((rid: string) => {
const el = document.querySelector<HTMLDivElement>(
`[data-testid="pdf-editor-run-${rid}"]`,
)!;
el.focus();
const tw = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let node: Text | null = null;
let rem = "Comprehensive".length;
while (tw.nextNode()) {
const n = tw.currentNode as Text;
const len = n.textContent?.length ?? 0;
if (rem <= len) {
node = n;
break;
}
rem -= len;
}
if (!node) return;
const sel = window.getSelection()!;
const range = document.createRange();
range.setStart(node, rem);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
document.execCommand("insertText", false, "\n");
}, id);
await page.waitForTimeout(80);
// Type a long tail at the END to force a width-wrap (triggers reflow).
await typeChars(page, id, " ZZZZ YYYY XXXX WWWW VVVV UUUU TTTT SSSS RRRR");
await blurRun(page, id);
const info = await readGlyphs(page, id);
if (!info) throw new Error("vanished");
const baseComp = lastBaselineOfWord(info, "Comprehensive");
const baseTool = firstBaselineOfWord(info, "toolkit");
expect(
baseComp !== null && baseTool !== null,
`words missing: ${JSON.stringify(info.text.slice(0, 60))}`,
).toBe(true);
expect(
baseTool!,
`mid-paragraph manual break removed by wrap: "Comprehensive" baseline=${baseComp}, "toolkit" baseline=${baseTool} (same line = break lost)`,
).toBeLessThan(baseComp! - 1);
});
test("wrap: deleting a manual break merges the two lines back together", async ({
page,
}) => {
await gotoWrap(page);
const id = await findPara(page, new RegExp(CARD));
if (!id) {
test.skip(true, "card paragraph missing");
return;
}
// Add a manual break at the end + a word, then delete back over the break.
await focusCaretEnd(page, id);
await page.evaluate(() => document.execCommand("insertText", false, "\n"));
await page.waitForTimeout(40);
await typeChars(page, id, "TAILWORD");
await page.waitForTimeout(60);
// Backspace the whole TAILWORD + the newline.
for (let i = 0; i < "TAILWORD\n".length; i++) {
await page.evaluate(() => document.execCommand("delete"));
await page.waitForTimeout(25);
}
await blurRun(page, id);
const info = await readGlyphs(page, id);
if (!info) throw new Error("vanished");
expect(info.text.replace(/\s+/g, " ")).not.toContain("TAILWORD");
expect(info.text.replace(/\s+/g, " ")).toMatch(/Comprehensive.*processing/);
});
test("grow mode: editing a paragraph (Enter + type) never blows the box off the page", async ({
page,
}) => {
// The reported bug: clicking a paragraph in the DEFAULT mode, pressing
// Enter and typing made the editing box expand past the page's right edge.
await gotoGrow(page);
const id = await findPara(page, new RegExp(INTRO));
if (!id) {
test.skip(true, "intro paragraph missing");
return;
}
await focusCaretEnd(page, id);
await page.evaluate(() => document.execCommand("insertText", false, "\n"));
await page.waitForTimeout(40);
await typeChars(page, id, "dfg");
await page.waitForTimeout(120);
// While focused: the editing box must not extend past the page's right
// edge (allowing a small tolerance).
const box = await page.evaluate((rid: string) => {
const el = document.querySelector<HTMLElement>(
`[data-testid="pdf-editor-run-${rid}"]`,
);
const pageEl = document.querySelector<HTMLElement>(
'[data-testid="pdf-editor-page-1"]',
);
if (!el || !pageEl) return null;
const er = el.getBoundingClientRect();
const pr = pageEl.getBoundingClientRect();
return { elRight: er.right, pageRight: pr.right };
}, id);
expect(box, "elements missing").not.toBeNull();
expect(
box!.elRight,
`editing box ran off the page (boxRight=${box!.elRight}, pageRight=${box!.pageRight})`,
).toBeLessThanOrEqual(box!.pageRight + 4);
// And the committed glyphs stay on the page too.
await blurRun(page, id);
const info = await readGlyphs(page, id);
if (!info) throw new Error("vanished");
const maxRight = Math.max(...info.glyphs.map((g) => g.right));
expect(
maxRight,
`committed text ran off the page (maxRight=${maxRight}, pageWidth=${info.pageWidth})`,
).toBeLessThanOrEqual(info.pageWidth);
// "dfg" landed and the original words survived.
expect(info.text.replace(/\s+/g, " ")).toContain("dfg");
expect(info.text.replace(/\s+/g, " ")).toMatch(/Stirling.*robust/);
});
test("wrap: two manual breaks both survive", async ({ page }) => {
await gotoWrap(page);
const id = await findPara(page, new RegExp(INTRO));
if (!id) {
test.skip(true, "intro paragraph missing");
return;
}
await typeChars(page, id, " ONEONE");
await page.evaluate(() => document.execCommand("insertText", false, "\n"));
await page.waitForTimeout(30);
await typeChars(page, id, "TWOTWO");
await page.evaluate(() => document.execCommand("insertText", false, "\n"));
await page.waitForTimeout(30);
await typeChars(page, id, "THREE");
await blurRun(page, id);
const info = await readGlyphs(page, id);
if (!info) throw new Error("vanished");
const b1 = firstBaselineOfWord(info, "ONEONE");
const b2 = firstBaselineOfWord(info, "TWOTWO");
const b3 = firstBaselineOfWord(info, "THREE");
expect(b1 !== null && b2 !== null && b3 !== null, info.text).toBe(true);
// Each marker on its own progressively-lower line.
expect(b2!).toBeLessThan(b1! - 1);
expect(b3!).toBeLessThan(b2! - 1);
});
});
function lastBaselineOfWord(info: ParaInfo, word: string): number | null {
// Find the run of glyphs spelling `word` (contiguous, same baseline) and
// return that baseline. Glyphs may be per-char or per-word.
const baselines = matchWordBaselines(info, word);
return baselines.length ? baselines[baselines.length - 1] : null;
}
function firstBaselineOfWord(info: ParaInfo, word: string): number | null {
const baselines = matchWordBaselines(info, word);
return baselines.length ? baselines[0] : null;
}
function matchWordBaselines(info: ParaInfo, word: string): number[] {
// Concatenate glyph texts in reading order (baseline desc, x asc) and find
// `word`; return the baseline(s) of the glyphs covering it.
const sorted = [...info.glyphs].sort((a, b) => {
if (Math.abs(a.baseline - b.baseline) > 2) return b.baseline - a.baseline;
return a.x - b.x;
});
let concat = "";
const owner: number[] = []; // glyph index per concatenated char
sorted.forEach((g, gi) => {
for (let i = 0; i < g.text.length; i++) {
concat += g.text[i];
owner.push(gi);
}
});
const idx = concat.indexOf(word);
if (idx < 0) return [];
const result: number[] = [];
const seen = new Set<number>();
for (let i = idx; i < idx + word.length; i++) {
const gi = owner[i];
if (gi != null && !seen.has(gi)) {
seen.add(gi);
result.push(sorted[gi].baseline);
}
}
return result;
}
@@ -0,0 +1,67 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import path from "path";
// Regeneration drops `sh` shadings, which the save-time repair puts back. It
// does NOT drop a pattern colour space fill - pin that so a PDFium upgrade
// cannot regress it silently into the same class of loss.
test("a pattern colour space fill survives regeneration", async ({ page }) => {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(
path.join(
import.meta.dirname,
"../test-fixtures/pattern-fill-sample.pdf",
),
);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(1200);
const result = await page.evaluate(() => {
/* eslint-disable @typescript-eslint/no-explicit-any */
const s = (window as any).__editor_store;
const doc = s.doc ?? s.document;
const m = doc.module;
const p = doc.page(0);
// Sample the middle of the patterned rectangle.
const sample = (): number[] => {
const w = 200;
const h = 200;
const bmp = m.FPDFBitmap_Create(w, h, 1);
m.FPDFBitmap_FillRect(bmp, 0, 0, w, h, 0xffffffff);
m.FPDF_RenderPageBitmap(bmp, p.pagePtr, 0, 0, w, h, 0, 0x01 | 0x10);
const buf = m.FPDFBitmap_GetBuffer(bmp);
const stride = m.FPDFBitmap_GetStride(bmp);
const heap = new Uint8Array(
(m.pdfium.wasmExports as any).memory.buffer,
buf,
stride * h,
);
const at = (x: number, y: number): number[] => {
const o = y * stride + x * 4;
return [heap[o], heap[o + 1], heap[o + 2]];
};
// Left, middle and right of the gradient band (page y=140 -> device y=60).
const out = [...at(40, 60), ...at(100, 60), ...at(160, 60)];
m.FPDFBitmap_Destroy(bmp);
return out;
};
const before = sample();
m.FPDFPage_GenerateContent(p.pagePtr);
const after = sample();
return { before, after, changed: before.join() !== after.join() };
/* eslint-enable @typescript-eslint/no-explicit-any */
});
// Red at the left, blue at the right: the axial shading really painted.
expect(result.before[0]).toBeGreaterThan(result.before[2]);
expect(result.before[8]).toBeGreaterThan(result.before[6]);
expect(result.changed).toBe(false);
});
@@ -0,0 +1,123 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page, Route } from "@playwright/test";
import path from "path";
/** Coverage for two save/open safety features. */
const ENCRYPTED = path.join(
import.meta.dirname,
"../test-fixtures/encrypted.pdf",
);
const SIGNED = path.join(
import.meta.dirname,
"../test-fixtures/signed-sample.pdf",
);
const SAMPLE = path.join(import.meta.dirname, "../test-fixtures/sample.pdf");
const ENCRYPTED_PASSWORD = "testpass123";
async function gotoEditor(page: Page): Promise<void> {
await page.route("**/encode-charcodes", (route: Route) => route.abort());
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 20_000,
});
}
async function upload(page: Page, file: string): Promise<void> {
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(file);
}
test.describe("PDF text editor - encrypted PDF password prompt", () => {
test("wrong password re-prompts, correct password opens the document", async ({
page,
}) => {
test.setTimeout(90_000);
await gotoEditor(page);
await upload(page, ENCRYPTED);
const submit = page.getByTestId("pdf-editor-password-submit");
await expect(submit).toBeVisible({ timeout: 20_000 });
const input = page
.getByTestId("pdf-editor-password-modal")
.locator("input")
.first();
// Wrong password -> prompt stays, shows the retry error.
await input.fill("definitely-wrong");
await submit.click();
await expect(page.getByText("Incorrect password - try again.")).toBeVisible(
{ timeout: 20_000 },
);
await expect(submit).toBeVisible();
// Correct password -> prompt closes and the page renders.
await input.fill(ENCRYPTED_PASSWORD);
await submit.click();
await expect(submit).toBeHidden({ timeout: 20_000 });
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
});
test("cancel dismisses the prompt without loading a document", async ({
page,
}) => {
test.setTimeout(60_000);
await gotoEditor(page);
await upload(page, ENCRYPTED);
const cancel = page.getByTestId("pdf-editor-password-cancel");
await expect(cancel).toBeVisible({ timeout: 20_000 });
await cancel.click();
await expect(cancel).toBeHidden();
expect(await page.getByTestId("pdf-editor-page-0").count()).toBe(0);
});
});
test.describe("PDF text editor - pre-save data-loss warning", () => {
test("signed PDF warns before saving, then downloads on confirm", async ({
page,
}) => {
test.setTimeout(90_000);
await gotoEditor(page);
await upload(page, SIGNED);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
// The first attempt surfaces the warning instead of downloading.
await page.getByTestId("pdf-editor-download").click();
const confirm = page.getByTestId("pdf-editor-save-risk-confirm");
await expect(confirm).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("pdf-editor-save-risk-modal")).toContainText(
/signature/i,
);
// Confirming downloads the rewritten copy and closes the modal.
const downloadPromise = page.waitForEvent("download");
await confirm.click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/\.pdf$/i);
await expect(confirm).toBeHidden();
});
test("a normal PDF saves without any warning", async ({ page }) => {
test.setTimeout(60_000);
await gotoEditor(page);
await upload(page, SAMPLE);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
const downloadPromise = page.waitForEvent("download");
await page.getByTestId("pdf-editor-download").click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/\.pdf$/i);
// The warning's confirm button must never have mounted for a plain PDF.
expect(await page.getByTestId("pdf-editor-save-risk-confirm").count()).toBe(
0,
);
});
});
@@ -0,0 +1,147 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import path from "path";
// Blur reflows an edited paragraph back to its locked width. Rebuilding the
// lines used ONE median space width for the whole paragraph - but justified
// text stretches its spaces line by line, so every line that had been set
// tighter than the median came out wider than it was authored and dropped its
// last word onto a line of its own.
//
// Typing eleven characters at the end of one line of the three-line Sample.pdf
// paragraph turned it into six lines: "...carry out various" lost "various",
// and the line below lost its tail too - on lines the user never touched. The
// reported symptom was "I type, and when I click off, text teleports to a new
// line below it".
//
// The gap that followed each word in the document is right there in the glyph
// positions; only a pair the reflow is genuinely joining for the first time
// needs an estimate.
const SAMPLE = path.join(
import.meta.dirname,
"../../../../public/samples/Sample.pdf",
);
async function open(page: Page): Promise<void> {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(SAMPLE);
await expect(page.getByTestId("pdf-editor-page-1")).toBeVisible({
timeout: 60_000,
});
await page.waitForTimeout(1500);
}
interface RunView {
id: string;
lineCount: number;
height: number;
text: string;
}
interface StoreView {
state: {
pages: {
runs: {
id: string;
text: string;
paragraphLineCount?: number;
bounds: { height: number };
}[];
}[];
};
}
function readRun(page: Page, id?: string): Promise<RunView | null> {
return page.evaluate((rid: string | undefined) => {
const s = (window as unknown as { __editor_store: StoreView })
.__editor_store;
for (const p of s.state.pages) {
for (const r of p.runs) {
const match = rid
? r.id === rid
: /Stirling\s+PDF\s+is\s+a\s+robust/.test(r.text);
if (match) {
return {
id: r.id,
lineCount: r.paragraphLineCount ?? 1,
height: r.bounds.height,
text: r.text,
};
}
}
}
return null;
}, id);
}
test.describe("PDF text editor - reflow keeps the lines it did not touch", () => {
test("a short edit adds one line, not three", async ({ page }) => {
await open(page);
const before = await readRun(page);
expect(before, "fixture paragraph not found").not.toBeNull();
expect(
before!.lineCount,
"fixture should be the three-line justified paragraph",
).toBe(3);
const testId = `pdf-editor-run-${before!.id}`;
const run = page.locator(`[data-testid="${testId}"]`);
await run.click();
await page.waitForTimeout(400);
// End of the FIRST visual line, so the edit has to wrap and the lines
// below it are the ones that must survive untouched.
await page.keyboard.press("End");
await page.waitForTimeout(300);
await page.keyboard.type(" HELLOWORLD", { delay: 60 });
await page.waitForTimeout(800);
// Click away, the way the user does - blur is what runs the reflow.
await page
.getByTestId("pdf-editor-page-1")
.click({ position: { x: 6, y: 6 } });
await page.waitForTimeout(3000);
const after = await readRun(page, before!.id);
expect(after).not.toBeNull();
// One wrapped line for the typed text. The bug turned three lines into
// six, because each line lost its last word as well.
expect(
after!.lineCount,
`an 11-character edit took the paragraph from ${before!.lineCount} lines to ${after!.lineCount}`,
).toBeLessThanOrEqual(before!.lineCount + 1);
});
test("words on untouched lines stay on their own line", async ({ page }) => {
await open(page);
const before = await readRun(page);
expect(before).not.toBeNull();
const testId = `pdf-editor-run-${before!.id}`;
await page.locator(`[data-testid="${testId}"]`).click();
await page.waitForTimeout(400);
await page.keyboard.press("End");
await page.waitForTimeout(300);
await page.keyboard.type(" HELLOWORLD", { delay: 60 });
await page.waitForTimeout(800);
await page
.getByTestId("pdf-editor-page-1")
.click({ position: { x: 6, y: 6 } });
await page.waitForTimeout(3000);
// The last line was never edited and had slack to spare, so a reflow that
// respects the document's own spacing leaves it exactly as it was.
const after = await readRun(page, before!.id);
expect(after).not.toBeNull();
expect(
after!.text.includes("rotating, compressing, and more."),
`the untouched closing line was re-broken: ${JSON.stringify(after!.text.slice(-80))}`,
).toBe(true);
});
});
@@ -0,0 +1,455 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import path from "path";
import type { EditorTestWindow } from "@app/tests/stubbed/editorTestTypes";
/** Regression coverage for three user-reported issues: 1. */
const SAMPLE = path.join(
import.meta.dirname,
"../../../../public/samples/Sample.pdf",
);
// Align / distribute / z-order now live inside the toolbar's "Arrange" menu.
// Open the menu, then act on or inspect the item.
async function clickArrange(page: Page, testid: string): Promise<void> {
await page.getByTestId("pdf-editor-arrange-menu").click();
await page.getByTestId(testid).click();
}
async function arrangeItemDisabled(
page: Page,
testid: string,
): Promise<boolean> {
await page.getByTestId("pdf-editor-arrange-menu").click();
const item = page.getByTestId(testid);
await item.waitFor({ state: "visible" });
const disabled = await item.isDisabled();
// Close via a neutral click in the top bar, NOT Escape: the editor's global
// Escape handler also clears the selection, which would break the next check.
await page.getByTestId("pdf-editor-zoom-percent").click();
await item.waitFor({ state: "hidden" });
return disabled;
}
async function open(page: Page, firstPage = 0): Promise<void> {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 15_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(SAMPLE);
await expect(page.getByTestId(`pdf-editor-page-${firstPage}`)).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(900);
}
async function runId(
page: Page,
pageIdx: number,
src: string,
): Promise<string> {
const id = await page.evaluate(
({ pageIdx, src }: { pageIdx: number; src: string }) => {
const s = (window as unknown as EditorTestWindow).__editor_store;
const r = s.doc
.page(pageIdx)
.runs.find((x) => new RegExp(src).test(x.text));
return r ? r.id : null;
},
{ pageIdx, src },
);
if (!id) throw new Error(`run /${src}/ not found`);
return id;
}
async function runText(
page: Page,
pageIdx: number,
id: string,
): Promise<string> {
return page.evaluate(
({ pageIdx, id }: { pageIdx: number; id: string }) => {
const r = (window as unknown as EditorTestWindow).__editor_store.doc
.page(pageIdx)
.runs.find((x) => x.id === id);
return r ? (r.text as string) : "(gone)";
},
{ pageIdx, id },
);
}
async function selRunCount(page: Page): Promise<number> {
return page.evaluate(
() =>
(window as unknown as EditorTestWindow).__editor_store.selection.value
.runIds.length,
);
}
/** Append text into a run via the contentEditable overlay, then blur. */
async function appendViaOverlay(
page: Page,
id: string,
text: string,
): Promise<void> {
await page.evaluate(
({ rid, text }: { rid: string; text: string }) => {
const el = document.querySelector<HTMLDivElement>(
`[data-testid="pdf-editor-run-${rid}"]`,
);
if (!el) throw new Error("run el missing");
el.focus();
const sel = window.getSelection()!;
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
document.execCommand("insertText", false, text);
},
{ rid: id, text },
);
await page.waitForTimeout(150);
await page.evaluate(
(rid: string) =>
document
.querySelector<HTMLElement>(`[data-testid="pdf-editor-run-${rid}"]`)
?.blur(),
id,
);
await page.waitForTimeout(900);
}
test.describe("PDF text editor - reported issue: align multi-select (real UI)", () => {
test("shift-click adds a 2nd run, which enables and applies align-left", async ({
page,
}) => {
// Use page-0 SINGLE-LINE runs at different x: a single one keeps align
// disabled.
await open(page, 0);
const a = await runId(page, 0, "10M\\+");
const b = await runId(page, 0, "Downloads");
await page.getByTestId(`pdf-editor-run-${a}`).click();
await page.waitForTimeout(150);
expect(await selRunCount(page)).toBe(1);
expect(await arrangeItemDisabled(page, "pdf-editor-align-left")).toBe(true);
// Shift-click B must ADD it (the bug was it failed to add).
await page
.getByTestId(`pdf-editor-run-${b}`)
.click({ modifiers: ["Shift"] });
await page.waitForTimeout(200);
expect(await selRunCount(page), "shift-click adds a 2nd run").toBe(2);
expect(await arrangeItemDisabled(page, "pdf-editor-align-left")).toBe(
false,
);
// And it actually aligns the two left edges.
await clickArrange(page, "pdf-editor-align-left");
await page.waitForTimeout(250);
const xs = await page.evaluate(
({ a, b }: { a: string; b: string }) => {
const pg = (
window as unknown as EditorTestWindow
).__editor_store.doc.page(0);
return [
pg.runs.find((r) => r.id === a)!.bounds.x,
pg.runs.find((r) => r.id === b)!.bounds.x,
];
},
{ a, b },
);
expect(Math.abs(xs[0] - xs[1])).toBeLessThan(1.5);
});
test("shift-clicking the same run twice toggles it back off (align re-disables)", async ({
page,
}) => {
await open(page, 0);
const a = await runId(page, 0, "10M\\+");
const b = await runId(page, 0, "Downloads");
await page.getByTestId(`pdf-editor-run-${a}`).click();
await page
.getByTestId(`pdf-editor-run-${b}`)
.click({ modifiers: ["Shift"] });
await page.waitForTimeout(200);
expect(await selRunCount(page)).toBe(2);
expect(await arrangeItemDisabled(page, "pdf-editor-align-left")).toBe(
false,
);
// Shift-click B again removes it -> back to 1 single-line run -> disabled.
await page
.getByTestId(`pdf-editor-run-${b}`)
.click({ modifiers: ["Shift"] });
await page.waitForTimeout(200);
expect(await selRunCount(page)).toBe(1);
expect(await arrangeItemDisabled(page, "pdf-editor-align-left")).toBe(true);
});
test("distribute needs three runs: enabled only after a 3rd shift-click", async ({
page,
}) => {
await open(page, 1);
const a = await runId(page, 1, "What\\s+is\\s+Stirling");
const b = await runId(page, 1, "Stirling\\s+PDF\\s+is\\s+a\\s+robust");
const c = await runId(page, 1, "Comprehensive\\s+toolkit");
await page.getByTestId(`pdf-editor-run-${a}`).click();
await page
.getByTestId(`pdf-editor-run-${b}`)
.click({ modifiers: ["Shift"] });
await page.waitForTimeout(150);
expect(await arrangeItemDisabled(page, "pdf-editor-distribute-v")).toBe(
true,
);
await page
.getByTestId(`pdf-editor-run-${c}`)
.click({ modifiers: ["Shift"] });
await page.waitForTimeout(200);
expect(await selRunCount(page)).toBe(3);
expect(await arrangeItemDisabled(page, "pdf-editor-distribute-v")).toBe(
false,
);
});
});
test.describe("PDF text editor - reported issue: align a single paragraph's lines", () => {
async function selectOne(page: Page, id: string): Promise<void> {
await page.evaluate(
(rid: string) =>
(
window as unknown as EditorTestWindow
).__editor_store.selection.selectOne(rid),
id,
);
await page.waitForTimeout(120);
}
async function lineRights(page: Page, id: string): Promise<number[]> {
return page.evaluate((rid: string) => {
const run = (window as unknown as EditorTestWindow).__editor_store.doc
.page(1)
.runs.find((r) => r.id === rid);
return (run?.paragraphLineSlots ?? []).map((s) =>
Math.max(...s.mergedFromBounds.map((b) => b.right)),
);
}, id);
}
async function lineLefts(page: Page, id: string): Promise<number[]> {
return page.evaluate((rid: string) => {
const run = (window as unknown as EditorTestWindow).__editor_store.doc
.page(1)
.runs.find((r) => r.id === rid);
return (run?.paragraphLineSlots ?? []).map((s) =>
Math.min(...s.mergedFromBounds.map((b) => b.x)),
);
}, id);
}
test("align buttons enable for a single multi-line paragraph", async ({
page,
}) => {
await open(page, 1);
const para = await runId(page, 1, "Stirling\\s+PDF\\s+is\\s+a\\s+robust");
await selectOne(page, para);
// Horizontal aligns enable on a single paragraph...
expect(await arrangeItemDisabled(page, "pdf-editor-align-left")).toBe(
false,
);
expect(await arrangeItemDisabled(page, "pdf-editor-align-right")).toBe(
false,
);
expect(await arrangeItemDisabled(page, "pdf-editor-align-center-h")).toBe(
false,
);
// ...but vertical aligns still need 2+ objects.
expect(await arrangeItemDisabled(page, "pdf-editor-align-top")).toBe(true);
});
test("align buttons stay disabled for a single single-line run", async ({
page,
}) => {
await open(page, 1);
const heading = await runId(page, 1, "What\\s+is\\s+Stirling");
await selectOne(page, heading);
expect(await arrangeItemDisabled(page, "pdf-editor-align-left")).toBe(true);
expect(await arrangeItemDisabled(page, "pdf-editor-align-right")).toBe(
true,
);
});
test("align-right flushes a paragraph's lines to a shared right edge; undo reverts", async ({
page,
}) => {
await open(page, 1);
const para = await runId(page, 1, "Stirling\\s+PDF\\s+is\\s+a\\s+robust");
await selectOne(page, para);
const before = await lineRights(page, para);
expect(before.length, "paragraph has multiple lines").toBeGreaterThan(1);
await clickArrange(page, "pdf-editor-align-right");
await page.waitForTimeout(300);
const after = await lineRights(page, para);
expect(
Math.max(...after) - Math.min(...after),
"all lines share a right edge",
).toBeLessThan(1.5);
// Undo restores the original per-line right edges.
await page.getByTestId("pdf-editor-undo").click();
await page.waitForTimeout(300);
const reverted = await lineRights(page, para);
for (let i = 0; i < before.length; i++) {
expect(reverted[i]).toBeCloseTo(before[i], 0);
}
});
test("align-left flushes a paragraph's lines to a shared left edge", async ({
page,
}) => {
await open(page, 1);
const para = await runId(page, 1, "Comprehensive\\s+toolkit");
await selectOne(page, para);
const before = await lineLefts(page, para);
expect(before.length).toBeGreaterThan(1);
await clickArrange(page, "pdf-editor-align-left");
await page.waitForTimeout(300);
const after = await lineLefts(page, para);
expect(
Math.max(...after) - Math.min(...after),
"all lines share a left edge",
).toBeLessThan(1.5);
});
});
test.describe("PDF text editor - reported issue: character insertion + font integrity", () => {
// Editor edits fire encode-charcodes; with no backend in the stubbed project
// an UNMOCKED call 401s and redirects to login.
test.beforeEach(async ({ page }) => {
await page.route("**/encode-charcodes", (route) => route.abort());
});
test("inserting a duplicate letter into an embedded-font run keeps text correct and emits no ydieresis tofu", async ({
page,
}) => {
await open(page, 1);
const id = await runId(page, 1, "Multi-Language\\s+Support");
await appendViaOverlay(page, id, "S");
const text = await runText(page, 1, id);
expect(text, "appended char is present").toMatch(/SupportS\s*$/);
expect(text, "no U+00FF tofu").not.toContain("ÿ");
});
test("inserting into a large heading section keeps text correct and tofu-free", async ({
page,
}) => {
await open(page, 0);
const id = await runId(page, 0, "Adobe\\s+Acrobat\\s+Alternative");
const before = await runText(page, 0, id);
await appendViaOverlay(page, id, "X");
const after = await runText(page, 0, id);
expect(after.length, "text grew by the inserted char").toBeGreaterThan(
before.length,
);
expect(after).toContain("X");
expect(after).not.toContain("ÿ");
});
test("inserting a character that already exists elsewhere in the run is tofu-free", async ({
page,
}) => {
await open(page, 1);
const id = await runId(page, 1, "Open\\s+Source");
// 'p' is not in "Open Source"; 'e'/'o'/'r'/'S' are. Insert an 'e'.
await appendViaOverlay(page, id, "e");
const text = await runText(page, 1, id);
expect(text).toMatch(/e\s*$/);
expect(text).not.toContain("ÿ");
});
test("inserting a duplicate char into a non-subset font reuses the embedded glyph via SetText, not the content-stream guess", async ({
page,
}) => {
// "Multi-Language Support" uses a NON-SUBSET embedded font.
await open(page, 1);
const id = await runId(page, 1, "Multi-Language\\s+Support");
await page.evaluate(
() => ((window as unknown as EditorTestWindow).__charcode_events = []),
);
await appendViaOverlay(page, id, "S");
const outcomes = await page.evaluate(
() =>
((window as unknown as EditorTestWindow).__charcode_events ?? []).map(
(e) => `${e.strategy}:${e.outcome}`,
) as string[],
);
expect(
outcomes,
`non-subset font must not use the content-stream guess; got ${JSON.stringify(outcomes)}`,
).not.toContain("content-stream:charcodes-ok");
const text = await runText(page, 1, id);
expect(text, "appended char is present").toMatch(/SupportS\s*$/);
expect(text, "no U+00FF tofu").not.toContain("ÿ");
});
test("character insertion telemetry records an emit attempt (font-reuse vs fallback)", async ({
page,
}) => {
// The window telemetry buffer records the outcome of every emit.
await open(page, 1);
const id = await runId(page, 1, "Multi-Language\\s+Support");
await page.evaluate(
() =>
((window as unknown as EditorTestWindow).__charcode_events =
[]) as unknown as void,
);
await appendViaOverlay(page, id, "S");
const outcomes = await page.evaluate(
() =>
((window as unknown as EditorTestWindow).__charcode_events ?? []).map(
(e) => e.outcome,
) as string[],
);
// At least one emit event fired for the edit.
expect(outcomes.length).toBeGreaterThan(0);
const known = [
"charcodes-ok",
"charcodes-call-failed",
"partial-coverage-fallback",
"no-strategy",
"no-font",
];
for (const o of outcomes) expect(known).toContain(o);
});
});
test.describe("PDF text editor - reported issue: bullet-to-item mapping", () => {
async function loadPage2(page: Page): Promise<void> {
await open(page, 0);
await page.evaluate(() => {
document
.querySelector('[data-testid="pdf-editor-page-1"]')
?.scrollIntoView();
document
.querySelector('[data-testid="pdf-editor-page-2"]')
?.scrollIntoView();
});
await expect(page.getByTestId("pdf-editor-page-2")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(2000);
}
// FIXED by the LineGrouper rework: bullets now attach to their list items
// instead of forming an orphan stacked bullet-only run.
test("bullets attach to their list items (not an orphan bullet-only run)", async ({
page,
}) => {
await loadPage2(page);
const hasOrphanBulletRun = await page.evaluate(() => {
const s = (window as unknown as EditorTestWindow).__editor_store;
const runs = s.doc.page(2).runs as Array<{ text: string }>;
// An "orphan" run is one whose text is ONLY bullets + whitespace
// and holds 2+ bullets (the stacked bullet column).
return runs.some(
(r) =>
/^[\s•]+$/.test(r.text) && (r.text.match(/•/g) ?? []).length >= 2,
);
});
expect(
hasOrphanBulletRun,
"bullets should attach to items, not form a stacked bullet-only run",
).toBe(false);
});
});
@@ -0,0 +1,143 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page, Route } from "@playwright/test";
import path from "path";
import type { EditorTestWindow } from "@app/tests/stubbed/editorTestTypes";
// Editing OBJECT-rotated text must keep the rotation, not force the re-emitted
// glyphs upright.
const ROTATED = path.join(
import.meta.dirname,
"../test-fixtures/rotated-text-sample.pdf",
);
const ROTATE90 = path.join(
import.meta.dirname,
"../test-fixtures/cropbox-rotate90.pdf",
);
async function rotatedRunMatrix(page: Page): Promise<{ a: number; b: number }> {
return page.evaluate(() => {
const s = (window as unknown as EditorTestWindow).__editor_store;
const r = s.doc.page(0).runs.find((x) => /Rotated/.test(x.text));
return r ? { a: r.matrix.a, b: r.matrix.b } : { a: 1, b: 0 };
});
}
test("editing rotated text keeps its rotation through save+reopen", async ({
page,
}: {
page: Page;
}) => {
test.setTimeout(120_000);
const errs: string[] = [];
page.on("pageerror", (e) => errs.push(e.message));
await page.route("**/encode-charcodes", (route: Route) => route.abort());
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 20_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(ROTATED);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(500);
// Sanity: the run loaded rotated (b is the sin component ~0.5).
const before = await rotatedRunMatrix(page);
expect(Math.abs(before.b)).toBeGreaterThan(0.3);
// Edit the run, then commit (blur).
const id = await page.evaluate(() => {
const s = (window as unknown as EditorTestWindow).__editor_store;
const r = s.doc.page(0).runs.find((x) => /Rotated/.test(x.text));
return r ? r.id : null;
});
expect(id, "rotated run found").toBeTruthy();
await page.evaluate((rid: string) => {
const el = document.querySelector<HTMLDivElement>(
`[data-testid="pdf-editor-run-${rid}"]`,
)!;
el.focus();
const sel = window.getSelection()!;
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
document.execCommand("insertText", false, "X");
}, id as string);
await page.waitForTimeout(200);
await page.evaluate((rid: string) => {
document
.querySelector<HTMLElement>(`[data-testid="pdf-editor-run-${rid}"]`)
?.blur();
}, id as string);
await page.waitForTimeout(1000);
// Save + reopen, then confirm the run is STILL rotated (not forced upright).
const downloadPromise = page.waitForEvent("download");
await page.getByTestId("pdf-editor-download").click();
const dl = await downloadPromise;
const stream = await dl.createReadStream();
const chunks: Buffer[] = [];
for await (const c of stream) chunks.push(c as Buffer);
await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({
name: "round-trip.pdf",
mimeType: "application/pdf",
buffer: Buffer.concat(chunks),
});
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(500);
const after = await rotatedRunMatrix(page);
expect(
Math.abs(after.b),
`rotation preserved after edit+save+reopen (matrix.b=${after.b})`,
).toBeGreaterThan(0.3);
expect(errs, `no page errors:\n${errs.join("\n")}`).toEqual([]);
});
test("inserting text on a /Rotate 90 page lands upright (counter-rotated)", async ({
page,
}: {
page: Page;
}) => {
test.setTimeout(90_000);
await page.route("**/encode-charcodes", (route: Route) => route.abort());
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 20_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(ROTATE90);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(500);
// Enter add-text mode and click the page to drop a new text run.
await page.getByTestId("pdf-editor-add-text").click();
await page
.getByTestId("pdf-editor-page-0")
.click({ position: { x: 120, y: 90 } });
await page.waitForTimeout(500);
// The inserted run must be counter-rotated so it reads upright on the
// 90deg-displayed page (matrix.b non-zero), not axis-aligned.
const m = await page.evaluate(() => {
const s = (window as unknown as EditorTestWindow).__editor_store;
const r = s.doc.page(0).runs.find((x) => /New text/.test(x.text));
return r ? { a: r.matrix.a, b: r.matrix.b } : null;
});
expect(m, "inserted run found").toBeTruthy();
expect(
Math.abs(m!.b),
`inserted text counter-rotated for the page (matrix=${JSON.stringify(m)})`,
).toBeGreaterThan(0.5);
});
@@ -0,0 +1,202 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import path from "path";
// Two ways the editor's selection covered less than the user asked for:
//
// * The loader only reads text for the first EAGER_PAGE_LIMIT (5) pages;
// the rest stay empty until they scroll into view. Ctrl+A collected run
// ids straight out of that half-filled model, so "select all" quietly
// stopped at page 5 and a font change applied to only part of the file.
// * Ctrl+click was swallowed whole by the ctrl-drag move gesture, whose
// pointerup bails out below a 0.5px threshold - so a Ctrl+click that
// didn't move was a no-op and could not extend the selection.
const MANY_PAGES_PDF = path.join(
import.meta.dirname,
"../test-fixtures/many-pages-sample.pdf",
);
const PARAGRAPH_PDF = path.join(
import.meta.dirname,
"../test-fixtures/paragraph-sample.pdf",
);
// Pages 6-8 of the fixture are past the eager window on purpose.
const TOTAL_PAGES = 8;
async function openEditor(page: import("@playwright/test").Page, file: string) {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(file);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 60_000,
});
await page.waitForTimeout(1500);
}
interface RunView {
id: string;
pageIndex: number;
fontSize: number;
selected: boolean;
}
/** Every run the model currently holds, tagged with its selection state. */
function runsInModel(
page: import("@playwright/test").Page,
): Promise<RunView[]> {
return page.evaluate(() => {
const w = window as unknown as {
__editor_store: {
state: {
pages: {
pageIndex: number;
runs: { id: string; fontSize: number }[];
}[];
};
selection: { value: { runIds: string[] } };
};
};
const store = w.__editor_store;
const selected = new Set(store.selection.value.runIds);
return store.state.pages.flatMap((p) =>
p.runs.map((r) => ({
id: r.id,
pageIndex: p.pageIndex,
fontSize: r.fontSize,
selected: selected.has(r.id),
})),
);
});
}
test.describe("PDF text editor - select all covers the whole document", () => {
test("Ctrl+A selects runs on pages past the eager read window", async ({
page,
}) => {
await openEditor(page, MANY_PAGES_PDF);
// Nothing has scrolled, so only the eager pages have been read. This is
// the precondition that made the bug invisible on short documents.
const before = await runsInModel(page);
const readBefore = new Set(before.map((r) => r.pageIndex));
expect(
readBefore.size,
"fixture must be longer than the eager read window",
).toBeLessThan(TOTAL_PAGES);
await page.keyboard.press("Control+a");
await page.waitForTimeout(800);
const after = await runsInModel(page);
const selectedPages = new Set(
after.filter((r) => r.selected).map((r) => r.pageIndex),
);
expect(
[...selectedPages].sort((a, b) => a - b),
"select all must reach every page, not just the ones already read",
).toEqual([...Array(TOTAL_PAGES).keys()]);
// And it must select every run it reached, not a sample of them.
expect(after.every((r) => r.selected)).toBe(true);
});
test("select all then change font size applies to the whole document", async ({
page,
}) => {
await openEditor(page, MANY_PAGES_PDF);
await page.keyboard.press("Control+a");
await page.waitForTimeout(800);
// The reported symptom: the change landed on some lines and not others.
const sizeInput = page.getByTestId("pdf-editor-font-size");
await sizeInput.fill("33");
await sizeInput.blur();
await page.waitForTimeout(1500);
const runs = await runsInModel(page);
expect(
runs.length,
"every page's text should be in the model",
).toBeGreaterThan(0);
const missed = runs.filter((r) => Math.abs(r.fontSize - 33) > 0.5);
expect(
missed.map((r) => `p${r.pageIndex}:${r.id}@${r.fontSize}`),
"no run may keep its old size",
).toEqual([]);
expect(new Set(runs.map((r) => r.pageIndex)).size).toBe(TOTAL_PAGES);
});
});
test.describe("PDF text editor - Ctrl+click extends the selection", () => {
test("Ctrl+clicking a second run selects both", async ({ page }) => {
await openEditor(page, PARAGRAPH_PDF);
const runs = page.locator('[data-testid^="pdf-editor-run-p0-"]');
expect(
await runs.count(),
"fixture needs at least two runs to multi-select",
).toBeGreaterThan(1);
const first = runs.nth(0);
const second = runs.nth(1);
await first.click();
await page.waitForTimeout(300);
const firstId = (await first.getAttribute("data-testid"))!.replace(
"pdf-editor-run-",
"",
);
// Ctrl+click with no drag: used to start a move gesture that cancelled
// itself on pointerup, leaving the selection untouched.
await second.click({ modifiers: ["Control"] });
await page.waitForTimeout(300);
const secondId = (await second.getAttribute("data-testid"))!.replace(
"pdf-editor-run-",
"",
);
const selected = await page.evaluate(() => {
const w = window as unknown as {
__editor_store: { selection: { value: { runIds: string[] } } };
};
return w.__editor_store.selection.value.runIds;
});
expect([...selected].sort()).toEqual([firstId, secondId].sort());
});
test("Ctrl+clicking a selected run deselects it again", async ({ page }) => {
await openEditor(page, PARAGRAPH_PDF);
const runs = page.locator('[data-testid^="pdf-editor-run-p0-"]');
const first = runs.nth(0);
const second = runs.nth(1);
// Build the two-run selection with Shift, which always worked, so the
// assertion below is about Ctrl+click removing a run - not adding one.
await first.click();
await second.click({ modifiers: ["Shift"] });
await page.waitForTimeout(300);
const both = await page.evaluate(() => {
const w = window as unknown as {
__editor_store: { selection: { value: { runIds: string[] } } };
};
return w.__editor_store.selection.value.runIds;
});
expect(both, "Shift+click should have selected two runs").toHaveLength(2);
await second.click({ modifiers: ["Control"] });
await page.waitForTimeout(300);
const firstId = (await first.getAttribute("data-testid"))!.replace(
"pdf-editor-run-",
"",
);
const selected = await page.evaluate(() => {
const w = window as unknown as {
__editor_store: { selection: { value: { runIds: string[] } } };
};
return w.__editor_store.selection.value.runIds;
});
expect(selected).toEqual([firstId]);
});
});
@@ -0,0 +1,90 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import path from "path";
// Clicking text places a caret. It must not move the page, and it must not
// move a single word. Every regression in this area has been something
// shifting at the moment of focus, so this pins the whole class.
const FIXTURES = ["sample", "mushroom-life", "stirling-marketing"];
interface Geometry {
pageX: number;
pageY: number;
words: Array<{ x: number; y: number }>;
}
function readGeometry(page: Page): Promise<Geometry> {
return page.evaluate(() => {
const pageEl = document.querySelector<HTMLElement>(
'[data-testid="pdf-editor-page-0"]',
);
const rect = pageEl?.getBoundingClientRect();
const words = [
...document.querySelectorAll<HTMLElement>("[data-pdf-editor-token]"),
].map((s) => {
const r = s.getBoundingClientRect();
return { x: +r.left.toFixed(2), y: +r.top.toFixed(2) };
});
return {
pageX: +(rect?.left ?? 0).toFixed(2),
pageY: +(rect?.top ?? 0).toFixed(2),
words,
};
});
}
for (const fixture of FIXTURES) {
test(`clicking a run moves nothing (${fixture})`, async ({
page,
}: {
page: Page;
}) => {
test.setTimeout(120_000);
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 45_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(
path.join(import.meta.dirname, `../test-fixtures/${fixture}.pdf`),
);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 45_000,
});
await page.waitForTimeout(2500);
const runs = page.locator('[data-testid^="pdf-editor-run-p0-"]');
const target = (await runs.count()) > 2 ? runs.nth(2) : runs.first();
await target.scrollIntoViewIfNeeded();
await page.waitForTimeout(400);
const before = await readGeometry(page);
const box = await target.boundingBox();
expect(box).not.toBeNull();
// A raw mouse event at a point already on screen: the framework's own
// click() scrolls the target into view first and would hide a regression.
await page.mouse.click(
box!.x + Math.min(30, box!.width / 3),
box!.y + Math.min(6, box!.height / 2),
);
await page.waitForTimeout(900);
const after = await readGeometry(page);
expect(Math.abs(after.pageX - before.pageX)).toBeLessThan(0.5);
expect(Math.abs(after.pageY - before.pageY)).toBeLessThan(0.5);
const shared = Math.min(before.words.length, after.words.length);
let worst = 0;
for (let i = 0; i < shared; i += 1) {
worst = Math.max(
worst,
Math.abs(after.words[i].x - before.words[i].x),
Math.abs(after.words[i].y - before.words[i].y),
);
}
expect(worst).toBeLessThan(0.5);
});
}
@@ -0,0 +1,119 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import path from "path";
import type { EditorTestWindow } from "@app/tests/stubbed/editorTestTypes";
const FIXTURES = ["sample", "subset-font-sample", "mushroom-life"];
const MAX_DRIFT_PX = 1.5;
interface DriftResult {
skipped?: string;
worst: number;
measured: number;
text: string;
}
async function openFixture(page: Page, name: string): Promise<void> {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(
path.join(import.meta.dirname, `../test-fixtures/${name}.pdf`),
);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(1200);
}
function measureDrift(page: Page): Promise<DriftResult> {
return page.evaluate(() => {
const empty = { worst: 0, measured: 0, text: "" };
const store = (window as unknown as EditorTestWindow).__editor_store;
const p0 = store.doc.page(0);
const pageEl = document.querySelector<HTMLElement>(
'[data-testid="pdf-editor-page-0"]',
);
const run = p0.runs[0];
if (!pageEl || !run) return { ...empty, skipped: "no page" };
const scale = pageEl.getBoundingClientRect().width / p0.width;
const el = document.querySelector<HTMLElement>(
`[data-testid="pdf-editor-run-${run.id}"]`,
);
if (!el) return { ...empty, skipped: "no overlay" };
const starts = run.charStartsX;
if (!starts) return { ...empty, skipped: "no captured positions" };
const line = el.querySelector<HTMLElement>("[data-pdf-editor-line]");
if (!line) return { ...empty, skipped: "not pinned" };
const spans = [
...line.querySelectorAll<HTMLElement>("[data-pdf-editor-token]"),
];
if (spans.length < 3) return { ...empty, skipped: "too few words" };
const originLeft = spans[0].getBoundingClientRect().left;
const originPdf = starts[0];
let at = 0;
let worst = 0;
let measured = 0;
for (const span of spans) {
const text = span.textContent ?? "";
const expectedPdf = starts[at];
if (at > 0 && text.trim().length > 0 && Number.isFinite(expectedPdf)) {
const actual = span.getBoundingClientRect().left - originLeft;
const expected = (expectedPdf - originPdf) * scale;
worst = Math.max(worst, Math.abs(actual - expected));
measured += 1;
}
at += text.length;
}
return { worst, measured, text: run.text };
});
}
for (const name of FIXTURES) {
test(`words sit on the engine's own pen origins (${name})`, async ({
page,
}: {
page: Page;
}) => {
await openFixture(page, name);
await expect(
page.locator('[data-testid^="pdf-editor-run-p0-"]').first(),
).toBeVisible({ timeout: 30_000 });
const result = await measureDrift(page);
test.skip(!!result.skipped, `cannot be pinned: ${result.skipped}`);
expect(result.measured).toBeGreaterThan(0);
expect(result.worst).toBeLessThan(MAX_DRIFT_PX);
});
test(`an edited run pins itself again once the edit settles (${name})`, async ({
page,
}: {
page: Page;
}) => {
await openFixture(page, name);
const target = page.locator('[data-testid^="pdf-editor-run-p0-"]').first();
await expect(target).toBeVisible({ timeout: 30_000 });
await target.click();
await page.keyboard.press("End");
await page.keyboard.type("Q");
await page.waitForTimeout(300);
await page
.locator('[data-testid="pdf-editor-page-0"]')
.click({ position: { x: 5, y: 5 } });
await page.waitForTimeout(1200);
await target.click();
await page.waitForTimeout(400);
const result = await measureDrift(page);
test.skip(!!result.skipped, `cannot be pinned: ${result.skipped}`);
expect(result.text).toContain("Q");
expect(result.measured).toBeGreaterThan(0);
expect(result.worst).toBeLessThan(MAX_DRIFT_PX);
});
}
@@ -0,0 +1,125 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import path from "path";
// Two toolbar changes:
//
// * The bold button was a second, weaker way to say what the font family
// picker already says, and it applied to whole runs rather than to the
// text the user had selected. The weight control is gone; Helvetica Bold
// and friends are still one dropdown pick away.
// * Fill colour and outline colour sat side by side, so the common case
// (change the text colour) had two pickers to choose between. Outline
// colour and width now live behind an advanced control.
const PARAGRAPH_PDF = path.join(
import.meta.dirname,
"../test-fixtures/paragraph-sample.pdf",
);
async function openEditor(page: import("@playwright/test").Page, file: string) {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(file);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 60_000,
});
await page.waitForTimeout(1500);
}
/** Select the first run and hand back its id. */
async function selectFirstRun(page: import("@playwright/test").Page) {
const run = page.locator('[data-testid^="pdf-editor-run-p0-"]').first();
await run.click();
await page.waitForTimeout(300);
return (await run.getAttribute("data-testid"))!.replace(
"pdf-editor-run-",
"",
);
}
function readRun(page: import("@playwright/test").Page, runId: string) {
return page.evaluate((rid) => {
const w = window as unknown as {
__editor_store: {
state: {
pages: {
runs: { id: string; fontId: string; strokeWidth?: number }[];
}[];
};
};
};
for (const p of w.__editor_store.state.pages) {
const r = p.runs.find((x) => x.id === rid);
if (r) return { fontId: r.fontId, strokeWidth: r.strokeWidth ?? 0 };
}
return null;
}, runId);
}
test.describe("PDF text editor - the weight control is gone", () => {
test("the toolbar has no bold button", async ({ page }) => {
await openEditor(page, PARAGRAPH_PDF);
await selectFirstRun(page);
await expect(page.getByTestId("pdf-editor-toolbar")).toBeVisible();
await expect(
page.getByTestId("pdf-editor-bold"),
"the bold weight control should have been dropped",
).toHaveCount(0);
// Italic is a style, not a weight, and stays.
await expect(page.getByTestId("pdf-editor-italic")).toBeVisible();
});
test("bold is still reachable through the font family picker", async ({
page,
}) => {
await openEditor(page, PARAGRAPH_PDF);
const runId = await selectFirstRun(page);
const before = await readRun(page, runId);
expect(before, "run should be in the model").not.toBeNull();
await page.getByTestId("pdf-editor-font-family").click();
await page.getByRole("option", { name: "Helvetica Bold" }).click();
await page.waitForTimeout(600);
const after = await readRun(page, runId);
expect(after!.fontId).toMatch(/Helvetica-Bold/);
});
});
test.describe("PDF text editor - one colour picker by default", () => {
test("outline colour and width are not in the toolbar", async ({ page }) => {
await openEditor(page, PARAGRAPH_PDF);
await selectFirstRun(page);
await expect(page.getByTestId("pdf-editor-colour")).toBeVisible();
await expect(
page.getByTestId("pdf-editor-outline-colour"),
"outline colour belongs behind the advanced control",
).toHaveCount(0);
await expect(page.getByTestId("pdf-editor-outline-width")).toHaveCount(0);
await expect(page.getByTestId("pdf-editor-colour-advanced")).toBeVisible();
});
test("the advanced control opens the fill/stroke pair and it still works", async ({
page,
}) => {
await openEditor(page, PARAGRAPH_PDF);
const runId = await selectFirstRun(page);
await page.getByTestId("pdf-editor-colour-advanced").click();
await expect(page.getByTestId("pdf-editor-outline-colour")).toBeVisible();
const width = page.getByTestId("pdf-editor-outline-width");
await expect(width).toBeVisible();
await width.fill("2");
await width.press("Enter");
await page.waitForTimeout(800);
const after = await readRun(page, runId);
expect(after!.strokeWidth).toBeCloseTo(2, 1);
});
});
@@ -0,0 +1,129 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import path from "path";
// PDFium's generator cannot emit Type 3 glyph procedures, so an edited run
// cannot keep its original face. It is NOT lost: the edit path substitutes a
// standard font, so the characters survive.
test.describe("PDF text editor - Type 3 fonts", () => {
test("editing a Type 3 run keeps the text, substituting a standard font", async ({
page,
}) => {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(
path.join(import.meta.dirname, "../test-fixtures/type3-sample.pdf"),
);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(800);
const before = await page.evaluate(() => {
/* eslint-disable @typescript-eslint/no-explicit-any */
const s = (window as any).__editor_store;
const doc = s.doc ?? s.document;
return doc.page(0).runs.map((r: any) => ({
id: r.id,
text: r.text,
locked: r.locked,
}));
/* eslint-enable @typescript-eslint/no-explicit-any */
});
const type3 = before.find((r: { text: string }) => r.text.includes("ab"));
expect(type3).toBeTruthy();
// A Type 3 run stays editable; locking it would remove working behaviour.
expect(type3.locked).toBe(false);
const target = page.locator(`[data-testid="pdf-editor-run-${type3.id}"]`);
await target.click();
await page.keyboard.press("End");
await page.keyboard.type("Z");
await page
.locator('[data-testid="pdf-editor-page-0"]')
.click({ position: { x: 5, y: 5 } });
await page.waitForTimeout(600);
const after = await page.evaluate(() => {
/* eslint-disable @typescript-eslint/no-explicit-any */
const s = (window as any).__editor_store;
const doc = s.doc ?? s.document;
return doc
.page(0)
.runs.map((r: any) => r.text)
.join("|");
/* eslint-enable @typescript-eslint/no-explicit-any */
});
expect(after).toContain("Z");
// The ordinary run alongside it must be untouched.
expect(after).toContain("Normal text");
});
// Sample.pdf is a Figma/Skia export: every font is Type 3, each visual line
// is split across several of them, and most /Widths entries are 0. Reusing
// those faces for an edit produced blank, zero-advance glyphs, so the
// replaced line collapsed into an unreadable pile a few points wide.
test("replacing a Type 3 line lays the glyphs out instead of stacking them", async ({
page,
}) => {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 30_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(
path.join(import.meta.dirname, "../../../../public/samples/Sample.pdf"),
);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(1500);
const NEXT = "An alternative to Adobe Acrobat";
const target = await page.evaluate(() => {
/* eslint-disable @typescript-eslint/no-explicit-any */
const s = (window as any).__editor_store;
const run = s.document
.page(0)
.runs.find((r: any) => r.text.includes("The Free Adobe"));
return run
? { id: run.id, fontSize: run.fontSize, width: run.bounds.width }
: null;
/* eslint-enable @typescript-eslint/no-explicit-any */
});
expect(target, "Sample.pdf tagline run").toBeTruthy();
// Drive it the way a user does: select the line, replace it, click away.
await page.locator(`[data-testid="pdf-editor-run-${target!.id}"]`).click();
await page.keyboard.press("ControlOrMeta+A");
await page.keyboard.press("Delete");
await page.keyboard.type(NEXT, { delay: 10 });
await page
.locator('[data-testid="pdf-editor-page-0"]')
.click({ position: { x: 5, y: 5 } });
await page.waitForTimeout(1500);
const after = await page.evaluate((id: string) => {
/* eslint-disable @typescript-eslint/no-explicit-any */
const s = (window as any).__editor_store;
const run = s.document.page(0).runs.find((r: any) => r.id === id);
return run ? { text: run.text, width: run.bounds.width } : null;
/* eslint-enable @typescript-eslint/no-explicit-any */
}, target!.id);
expect(after?.text).toBe(NEXT);
// Every character must contribute an advance. The collapsed regression
// measured ~0.09em per char; a real Latin line averages well over 0.3em.
const visible = NEXT.replace(/\s+/gu, "").length;
const minWidth = visible * target!.fontSize * 0.3;
expect(
after?.width ?? 0,
`line width ${after?.width} is below ${minWidth}: glyphs stacked instead of advancing`,
).toBeGreaterThan(minWidth);
});
});
@@ -0,0 +1,278 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page, Route } from "@playwright/test";
import path from "path";
import type { EditorTestWindow } from "@app/tests/stubbed/editorTestTypes";
import { downloadBytes, saveAndDownload } from "@app/tests/stubbed/saveHelpers";
/** Client-side Unicode fallback font (Noto Sans, embedded on demand). */
const SAMPLE = path.join(
import.meta.dirname,
"../../../../public/samples/Sample.pdf",
);
// Scripts the bundled Noto Sans covers - these survive a round-trip.
const COVERED = [{ name: "Cyrillic", text: "Привет" }];
// Scripts the bundled font lacks - dropped cleanly (no tofu) on save.
const UNCOVERED = [
{ name: "CJK", text: "日本語" },
{ name: "astral", text: "😀" },
];
// A Latin anchor from Sample.pdf's first run, used to prove the surrounding
// text is intact after an uncovered script is dropped.
const LATIN_ANCHOR = "Acrobat";
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])/;
async function gotoEditor(page: Page): Promise<Promise<unknown>> {
await page.route("**/encode-charcodes", (route: Route) => route.abort());
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
// Capture the fallback-font fetch (fired on mount) so we can await it before
// editing - the embed is sync and needs the bytes cached.
const fontLoaded = page
.waitForResponse((r) => /NotoSans-Regular\.ttf/.test(r.url()), {
timeout: 20_000,
})
.catch(() => null);
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 20_000,
});
return fontLoaded;
}
// Load SAMPLE, append `text` to the first run, blur, save, and reopen the
// produced bytes.
async function appendSaveReopen(
page: Page,
text: string,
expectRisk: boolean,
): Promise<{ reopened: string; errs: string[]; runId: string }> {
const errs: string[] = [];
page.on("pageerror", (e) => errs.push(e.message));
const fontLoaded = await gotoEditor(page);
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(SAMPLE);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await fontLoaded; // bytes cached before we edit
await page.waitForTimeout(400);
// Append the sample text to the first run and commit (blur).
const id = await page.evaluate(() => {
const s = (window as unknown as EditorTestWindow).__editor_store;
return s.doc.page(0).runs[0]?.id ?? null;
});
expect(id, "page 0 has at least one run").toBeTruthy();
await page.evaluate(
({ rid, txt }: { rid: string; txt: string }) => {
const el = document.querySelector<HTMLDivElement>(
`[data-testid="pdf-editor-run-${rid}"]`,
)!;
el.focus();
const sel = window.getSelection()!;
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
document.execCommand("insertText", false, " " + txt);
},
{ rid: id as string, txt: text },
);
await page.waitForTimeout(200);
await page.evaluate((rid: string) => {
document
.querySelector<HTMLElement>(`[data-testid="pdf-editor-run-${rid}"]`)
?.blur();
}, id as string);
await page.waitForTimeout(1000);
// Save, then reopen the produced bytes.
const saved = await downloadBytes(await saveAndDownload(page, expectRisk));
await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({
name: "round-trip.pdf",
mimeType: "application/pdf",
buffer: saved,
});
await expect(
page.locator('[data-testid^="pdf-editor-run-p0-"]').first(),
).toBeVisible({ timeout: 30_000 });
await page.waitForTimeout(500);
const reopened = await page.evaluate(() => {
const s = (window as unknown as EditorTestWindow).__editor_store;
return s.doc
.page(0)
.runs.map((r) => r.text)
.join("");
});
return { reopened, errs, runId: id as string };
}
for (const { name, text } of COVERED) {
test(`non-Latin (${name}) survives a save+reopen via the embedded fallback font`, async ({
page,
}) => {
test.setTimeout(120_000);
// Covered scripts embed cleanly, so nothing is dropped and no save-risk
// modal is raised.
const { reopened, errs } = await appendSaveReopen(page, text, false);
// The reopened document must still carry the text (embedded, not dropped).
expect(reopened).toContain(text);
expect(errs, `no page errors:\n${errs.join("\n")}`).toEqual([]);
});
}
for (const { name, text } of UNCOVERED) {
test(`non-Latin (${name}) is dropped cleanly (no tofu) when the fallback lacks it`, async ({
page,
}) => {
test.setTimeout(120_000);
// The bundled font lacks this script, so the chars are dropped and the
// save-risk modal always gates the save.
const { reopened, errs } = await appendSaveReopen(page, text, true);
// The bundled font lacks this script, so it is dropped on save.
expect(reopened).not.toContain(text);
expect(reopened).not.toContain("ÿ");
expect(LONE_SURROGATE.test(reopened)).toBe(false);
expect(reopened).toContain(LATIN_ANCHOR);
expect(errs, `no page errors:\n${errs.join("\n")}`).toEqual([]);
});
}
/** RTL / bidi insertion. */
const RTL_SAMPLES = [
{ name: "Arabic", text: "مرحبا" },
{ name: "Hebrew", text: "שלום" },
];
for (const { name, text } of RTL_SAMPLES) {
test(`RTL (${name}) insert: model, in-bounds run, save+reopen`, async ({
page,
}) => {
test.setTimeout(120_000);
// Read the edited run's bounds before save so we can check it stays on page.
const errs: string[] = [];
page.on("pageerror", (e) => errs.push(e.message));
const fontLoaded = await gotoEditor(page);
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(SAMPLE);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 30_000,
});
await fontLoaded;
await page.waitForTimeout(400);
const id = await page.evaluate(() => {
const s = (window as unknown as EditorTestWindow).__editor_store;
return s.doc.page(0).runs[0]?.id ?? null;
});
expect(id, "page 0 has at least one run").toBeTruthy();
await page.evaluate(
({ rid, txt }: { rid: string; txt: string }) => {
const el = document.querySelector<HTMLDivElement>(
`[data-testid="pdf-editor-run-${rid}"]`,
)!;
el.focus();
const sel = window.getSelection()!;
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
document.execCommand("insertText", false, " " + txt);
},
{ rid: id as string, txt: text },
);
await page.waitForTimeout(200);
await page.evaluate((rid: string) => {
document
.querySelector<HTMLElement>(`[data-testid="pdf-editor-run-${rid}"]`)
?.blur();
}, id as string);
await page.waitForTimeout(1000);
// (a) model text carries the inserted RTL string.
const model = await page.evaluate((rid: string) => {
const s = (window as unknown as EditorTestWindow).__editor_store;
return s.doc.page(0).runs.find((r) => r.id === rid)?.text ?? "";
}, id as string);
expect(model).toContain(text);
// (b) the edited run stays within page bounds after the edit.
const fits = await page.evaluate((rid: string) => {
const s = (window as unknown as EditorTestWindow).__editor_store;
const pg = s.doc.page(0);
const r = pg.runs.find((x) => x.id === rid)!;
return { boundsRight: r.bounds.x + r.bounds.width, pageWidth: pg.width };
}, id as string);
expect(
fits.boundsRight,
"RTL run must not extend past the page width",
).toBeLessThanOrEqual(fits.pageWidth + 2);
// (c) save+reopen preserves the text. Noto Sans lacks Arabic/Hebrew, so
// these are always dropped and the save-risk modal always gates the save.
const saved = await downloadBytes(await saveAndDownload(page, true));
await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({
name: "rtl-round-trip.pdf",
mimeType: "application/pdf",
buffer: saved,
});
await expect(
page.locator('[data-testid^="pdf-editor-run-p0-"]').first(),
).toBeVisible({ timeout: 30_000 });
await page.waitForTimeout(500);
const reopened = await page.evaluate(() => {
const s = (window as unknown as EditorTestWindow).__editor_store;
return s.doc
.page(0)
.runs.map((r) => r.text)
.join("");
});
// Noto Sans lacks Arabic/Hebrew, so the script is dropped on save - but
// cleanly (no U+00FF tofu) with the Latin content preserved.
expect(reopened).not.toContain(text);
expect(reopened).not.toContain("ÿ");
expect(reopened).toContain(LATIN_ANCHOR);
expect(errs, `no page errors:\n${errs.join("\n")}`).toEqual([]);
});
}
test("bidi mix preserves logical character order in the model", async ({
page,
}) => {
test.setTimeout(120_000);
// Logical order is the order the characters are typed, not the visual order.
const BIDI = "abc مرحبا 123";
// The Arabic span has no Noto coverage, so it is dropped and the save-risk
// modal gates the save.
const { reopened, errs } = await appendSaveReopen(page, BIDI, true);
// The Arabic span is dropped (no Noto coverage), but the covered Latin/digit
// parts survive in logical order without tofu or reordering.
expect(reopened).not.toContain("مرحبا");
expect(reopened).not.toContain("ÿ");
expect(reopened).toContain("abc");
expect(reopened).toContain("123");
expect(reopened.indexOf("abc")).toBeLessThan(reopened.indexOf("123"));
expect(errs, `no page errors:\n${errs.join("\n")}`).toEqual([]);
});
@@ -0,0 +1,199 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import path from "path";
import type { EditorTestWindow } from "@app/tests/stubbed/editorTestTypes";
/** Drives the real UI control by control; the screenshots are the record. */
const SAMPLE = path.join(
import.meta.dirname,
"../test-fixtures/user-sample.pdf",
);
// Screenshots are evidence, not source: keep them out of the repo tree.
const SHOTS = path.join(
import.meta.dirname,
"../../../../test-results/walkthrough-shots",
);
async function openEditor(page: Page): Promise<void> {
await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pdf-editor-root")).toBeVisible({
timeout: 45_000,
});
await page
.locator('[data-testid="pdf-editor-file-input"]')
.setInputFiles(SAMPLE);
await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({
timeout: 45_000,
});
await page.waitForTimeout(900);
}
/** Select the first editable run through the page, as a user would. */
async function selectFirstRun(page: Page): Promise<string> {
const id = await page.evaluate(() => {
const store = (window as unknown as EditorTestWindow).__editor_store;
const run = store.doc.page(0).runs.find((r) => !r.locked && r.text.trim());
if (run) store.selection.selectOne(run.id);
return run?.id ?? "";
});
expect(id).toBeTruthy();
await page.waitForTimeout(200);
return id;
}
test("the new toolbar controls are present and usable", async ({
page,
}: {
page: Page;
}) => {
test.setTimeout(140_000);
await openEditor(page);
// Outline controls live behind the advanced-colour button.
await selectFirstRun(page);
await page.getByTestId("pdf-editor-colour-advanced").click();
await expect(page.getByTestId("pdf-editor-outline-colour")).toBeVisible();
await expect(page.getByTestId("pdf-editor-outline-width")).toBeVisible();
// The device-font picker replaced the plain family Select.
await expect(page.getByTestId("pdf-editor-font-family")).toBeVisible();
await page.screenshot({ path: path.join(SHOTS, "01-toolbar.png") });
});
test("giving a run an outline changes it, and undo takes it back", async ({
page,
}: {
page: Page;
}) => {
test.setTimeout(140_000);
await openEditor(page);
const runId = await selectFirstRun(page);
const before = await page.evaluate((id: string) => {
const store = (window as unknown as EditorTestWindow).__editor_store;
const run = store.doc.page(0).runs.find((r) => r.id === id);
return { stroke: run?.stroke ?? null, width: run?.strokeWidth ?? 0 };
}, runId);
expect(before.stroke).toBeNull();
await page.getByTestId("pdf-editor-colour-advanced").click();
await page.getByTestId("pdf-editor-outline-width").fill("2");
await page.getByTestId("pdf-editor-outline-width").press("Enter");
await page.waitForTimeout(400);
const after = await page.evaluate((id: string) => {
const store = (window as unknown as EditorTestWindow).__editor_store;
const run = store.doc.page(0).runs.find((r) => r.id === id);
return {
stroke: run?.stroke ?? null,
width: run?.strokeWidth ?? 0,
renderMode: run?.renderMode ?? 0,
};
}, runId);
expect(after.width).toBeGreaterThan(0);
// Width alone is invisible; the run must also move to a stroking mode.
expect(after.renderMode).toBe(2);
await page.screenshot({ path: path.join(SHOTS, "02-outline-applied.png") });
await page.getByTestId("pdf-editor-undo").click();
await page.waitForTimeout(400);
const reverted = await page.evaluate((id: string) => {
const store = (window as unknown as EditorTestWindow).__editor_store;
const run = store.doc.page(0).runs.find((r) => r.id === id);
return { width: run?.strokeWidth ?? 0, renderMode: run?.renderMode ?? 0 };
}, runId);
expect(reverted.width).toBe(0);
expect(reverted.renderMode).toBe(0);
});
test("rulers and guides can be switched on from the sidebar", async ({
page,
}: {
page: Page;
}) => {
test.setTimeout(140_000);
await openEditor(page);
await page.getByTestId("pdf-editor-tab-document").click();
const toggle = page.getByTestId("pdf-editor-toggle-rulers");
await expect(toggle).toBeVisible();
await toggle.click();
await page.waitForTimeout(500);
const rulers = page.getByTestId("pdf-editor-rulers-0");
await expect(rulers).toBeVisible();
await expect(page.getByTestId("pdf-editor-ruler-top-0")).toBeVisible();
await expect(page.getByTestId("pdf-editor-ruler-left-0")).toBeVisible();
await page.screenshot({ path: path.join(SHOTS, "03-rulers.png") });
await toggle.click();
await page.waitForTimeout(300);
await expect(rulers).toHaveCount(0);
});
test("find offers the new matching options", async ({
page,
}: {
page: Page;
}) => {
test.setTimeout(140_000);
await openEditor(page);
await page.keyboard.press("Control+f");
await page.waitForTimeout(400);
await expect(page.getByTestId("pdf-editor-find-match-case")).toBeVisible();
await expect(page.getByTestId("pdf-editor-find-whole-word")).toBeVisible();
await expect(
page.getByTestId("pdf-editor-find-ignore-accents"),
).toBeVisible();
await page.screenshot({ path: path.join(SHOTS, "04-find.png") });
});
test("the sidebar exposes the spellcheck control", async ({
page,
}: {
page: Page;
}) => {
test.setTimeout(140_000);
await openEditor(page);
await page.getByTestId("pdf-editor-tab-document").click();
const control = page.getByTestId("pdf-editor-spellcheck");
await expect(control).toBeVisible();
await expect(
page.getByTestId("pdf-editor-spellcheck-language"),
).toBeVisible();
await page.screenshot({ path: path.join(SHOTS, "05-sidebar.png") });
});
test("a save still produces a readable PDF after the new passes run", async ({
page,
}: {
page: Page;
}) => {
test.setTimeout(140_000);
await openEditor(page);
const runId = await selectFirstRun(page);
// Make a real edit so a page is regenerated and the save-time repair runs.
await page.evaluate((id: string) => {
const el = document.querySelector<HTMLDivElement>(
`[data-testid="pdf-editor-run-${id}"]`,
);
el?.focus();
}, runId);
await page.keyboard.type("X");
await page.keyboard.press("Tab");
await page.waitForTimeout(600);
const download = page.waitForEvent("download", { timeout: 60_000 });
await page.getByTestId("pdf-editor-download").click();
// A signed/risky document would gate here; this fixture is not one.
const file = await download;
const stream = await file.createReadStream();
const chunks: Buffer[] = [];
for await (const chunk of stream) chunks.push(chunk as Buffer);
const bytes = Buffer.concat(chunks);
expect(bytes.length).toBeGreaterThan(1000);
expect(bytes.subarray(0, 5).toString("latin1")).toBe("%PDF-");
expect(bytes.subarray(-2048).toString("latin1")).toContain("%%EOF");
});

Some files were not shown because too many files have changed in this diff Show More