mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c10bc1e4e | ||
|
|
8ad77e0e95 | ||
|
|
2cf355c5cd | ||
|
|
c57a2a45de | ||
|
|
d30faf246b | ||
|
|
f6661a8f87 | ||
|
|
ceeec53df4 | ||
|
|
4ef2e3811c | ||
|
|
31d52d4c32 |
@@ -220,6 +220,42 @@ jobs:
|
||||
echo "app_short=${APP_HASH:0:8}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# The Stirling account previews connect to. Derived from the ref rather than stored as a URL
|
||||
# so it cannot drift from the key: a mismatched pair is accepted by the browser and rejected
|
||||
# by Supabase, surfacing much later as "session expired" on Usage rather than at sign-in.
|
||||
# Secret only to match Saas-Dev-Deploy.yml, which owns the same value; a project ref is not
|
||||
# itself sensitive, which is why SAAS_API_BASE_URL next to it is a plain variable.
|
||||
- name: Resolve Stirling account config
|
||||
id: saas
|
||||
env:
|
||||
PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }}
|
||||
API_BASE_OVERRIDE: ${{ vars.SAAS_API_BASE_URL }}
|
||||
run: |
|
||||
# Set, this is the one value both halves use: the browser's portal reads and the backend's
|
||||
# register/entitlement calls have to land on the same SaaS, and nothing checks that they
|
||||
# do. Unset, only the backend gets a base, from its own compiled-in default.
|
||||
API_BASE="${API_BASE_OVERRIDE:-https://stirling.com/app}"
|
||||
echo "backend_base=${API_BASE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ -z "${PROJECT_REF}" ]; then
|
||||
echo "Not configured for this environment: the preview will build without a Stirling"
|
||||
echo "account, and the connect dialog will say so. To wire one up, set on the"
|
||||
echo "pr-preview environment the secrets SAAS_DB_PROJECT_REF and"
|
||||
echo "SAAS_SUPABASE_PUBLISHABLE_KEY, both from the same Supabase project."
|
||||
echo "supabase_url=" >> "$GITHUB_OUTPUT"
|
||||
echo "frontend_base=" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# Only whether, not which: the ref is a secret here, so Actions masks it out of any
|
||||
# line it appears in, derived URL included.
|
||||
echo "Stirling account configured, at ${API_BASE}."
|
||||
echo "supabase_url=https://${PROJECT_REF}.supabase.co" >> "$GITHUB_OUTPUT"
|
||||
# Deliberately the override and not API_BASE: the backend's default is a subpath URL
|
||||
# nobody has confirmed answers /api/v1, and prod CORS does not list preview hostnames,
|
||||
# so portal reads stay off until someone sets a base they have checked. Empty leaves the
|
||||
# committed .env default alone, which is the clean "not configured" state.
|
||||
echo "frontend_base=${API_BASE_OVERRIDE}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Check if image exists
|
||||
id: check-image
|
||||
run: |
|
||||
@@ -246,6 +282,9 @@ jobs:
|
||||
build-args: |
|
||||
VERSION_TAG=v2-alpha
|
||||
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
|
||||
VITE_SUPABASE_URL=${{ steps.saas.outputs.supabase_url }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${{ secrets.SAAS_SUPABASE_PUBLISHABLE_KEY }}
|
||||
VITE_SAAS_API_URL=${{ steps.saas.outputs.frontend_base }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Set up SSH
|
||||
@@ -279,6 +318,13 @@ jobs:
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true"
|
||||
STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL: "${{ steps.saas.outputs.backend_base }}"
|
||||
# Off so preview traffic never accrues against a real wallet or trips its cap. The
|
||||
# 402 gate is separate and stays on, so gating is still testable here.
|
||||
STIRLING_BILLING_ACCOUNT_LINK_METERING_ENABLED: "false"
|
||||
# Stated rather than inferred from the request: the callback has to come back to the
|
||||
# preview hostname, not to the container's own :8080 behind this proxy.
|
||||
SYSTEM_FRONTENDURL: "https://${V2_PORT}.ssl.stirlingpdf.cloud"
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
SECURITY_INITIALLOGIN_USERNAME: "${TEST_LOGIN_USERNAME}"
|
||||
SECURITY_INITIALLOGIN_PASSWORD: "${TEST_LOGIN_PASSWORD}"
|
||||
|
||||
@@ -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
|
||||
|
||||
+598
@@ -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;
|
||||
}
|
||||
}
|
||||
+13
@@ -338,6 +338,19 @@ public class ConfigController {
|
||||
// Premium/Enterprise settings
|
||||
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
||||
|
||||
// Whether this instance can link a Stirling (SaaS) account at all. The account-link
|
||||
// beans live in :proprietary and are @ConditionalOnProperty on this same key, so when
|
||||
// it is off they are absent and /api/v1/account-link/* returns 404. The frontend cannot
|
||||
// tell that 404 apart from "not linked yet", so it needs this told to it explicitly
|
||||
// before it can prompt anyone to link. Read from the environment rather than
|
||||
// AccountLinkProperties because :core must not depend on :proprietary.
|
||||
configData.put(
|
||||
"accountLinkAvailable",
|
||||
applicationContext
|
||||
.getEnvironment()
|
||||
.getProperty(
|
||||
"stirling.billing.account-link.enabled", Boolean.class, false));
|
||||
|
||||
// AI Engine settings
|
||||
ApplicationProperties.AiEngine aiEngineConfig = applicationProperties.getAiEngine();
|
||||
configData.put("aiEngineEnabled", aiEngineConfig.isEnabled());
|
||||
|
||||
+4
-2
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
+2
@@ -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",
|
||||
|
||||
+516
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+755
@@ -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');
|
||||
}
|
||||
}
|
||||
+340
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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));
|
||||
|
||||
+6
-7
@@ -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
|
||||
|
||||
Binary file not shown.
+13
@@ -18,6 +18,19 @@ public enum FailureActionId {
|
||||
|
||||
DISMISS(Execution.SERVER, "Dismiss"),
|
||||
|
||||
/**
|
||||
* Open the failed operation in the client with its document, for the owner to run again
|
||||
* themselves. Not a re-run: the settings are theirs to check first.
|
||||
*/
|
||||
OPEN_IN_TOOL(Execution.CLIENT, "Retry"),
|
||||
|
||||
/**
|
||||
* Ask the owner for the password and unlock the document in their client. Re-running is implied
|
||||
* rather than named: an id says what a caller must supply, and a {@link
|
||||
* FailureActionSlot#RESOLUTION} runs the failed work again once it has it.
|
||||
*/
|
||||
DECRYPT(Execution.CLIENT, "Decrypt and retry"),
|
||||
|
||||
/** Open the document behind the incident, in whichever client can resolve its id. */
|
||||
VIEW_FILE(Execution.CLIENT, "View file"),
|
||||
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
/** Placement intent, not layout: the client promotes, knowing what it can actually run. */
|
||||
public enum FailureActionSlot {
|
||||
|
||||
/** The action that resolves the failure. At most one per kind. */
|
||||
RESOLUTION,
|
||||
|
||||
/** Offered alongside the resolution, for a caller the resolution is not aimed at. */
|
||||
SECONDARY,
|
||||
|
||||
/** Available but folded away: correct, rarely what anyone wants to press next. */
|
||||
OVERFLOW
|
||||
}
|
||||
+55
-37
@@ -1,8 +1,12 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import static stirling.software.proprietary.failure.FailureActionId.DECRYPT;
|
||||
import static stirling.software.proprietary.failure.FailureActionId.DISMISS;
|
||||
import static stirling.software.proprietary.failure.FailureActionId.OPEN_IN_TOOL;
|
||||
import static stirling.software.proprietary.failure.FailureActionId.VIEW_FILE;
|
||||
import static stirling.software.proprietary.failure.FailureActionId.VIEW_IN_PROCESSOR;
|
||||
import static stirling.software.proprietary.failure.FailureActionSlot.OVERFLOW;
|
||||
import static stirling.software.proprietary.failure.FailureActionSlot.SECONDARY;
|
||||
import static stirling.software.proprietary.failure.FailureAudience.ANYONE_WHO_SEES;
|
||||
import static stirling.software.proprietary.failure.FailureAudience.OWNER;
|
||||
import static stirling.software.proprietary.failure.FailureAudience.TEAM_REVIEWER;
|
||||
@@ -21,11 +25,8 @@ import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* The registry of failure kinds, described as data: a stable id, i18n keys and an English fallback
|
||||
* like {@code ExceptionUtils.ErrorCode}, plus the facets a review surface needs.
|
||||
*
|
||||
* <p>A new kind ships as a registry entry plus copy. Each offer also says who it is for, since one
|
||||
* incident is read both by whoever hit it and by whoever reviews after them.
|
||||
* The registry of failure kinds as data: id, i18n keys, English fallback, plus the facets a review
|
||||
* surface needs. A new kind ships as an entry plus copy; each offer says who it is for and where.
|
||||
*/
|
||||
@Getter
|
||||
public enum FailureKind {
|
||||
@@ -36,9 +37,12 @@ public enum FailureKind {
|
||||
FailureScope.FILE,
|
||||
errorCodes("E004"),
|
||||
fallback("This document is password-protected, so the pipeline could not read it."),
|
||||
offer(VIEW_FILE, OWNER),
|
||||
offer(VIEW_IN_PROCESSOR, TEAM_REVIEWER),
|
||||
offer(DISMISS, ANYONE_WHO_SEES)),
|
||||
// The password is the fix; the owner's own document is the runner-up.
|
||||
resolution(DECRYPT, OWNER),
|
||||
global(VIEW_FILE, OWNER, SECONDARY),
|
||||
global(VIEW_IN_PROCESSOR, TEAM_REVIEWER, OVERFLOW),
|
||||
global(OPEN_IN_TOOL, OWNER, OVERFLOW),
|
||||
global(DISMISS, ANYONE_WHO_SEES, OVERFLOW)),
|
||||
|
||||
UNKNOWN(
|
||||
FailureStage.INTERNAL,
|
||||
@@ -47,11 +51,11 @@ public enum FailureKind {
|
||||
FailureScope.RUN,
|
||||
noErrorCodes(),
|
||||
fallback("This run failed for a reason Stirling does not yet recognise."),
|
||||
// Same order as every other kind: declaration order is display order, so the document
|
||||
// leads wherever it is offered rather than moving between failures.
|
||||
offer(VIEW_FILE, OWNER),
|
||||
offer(VIEW_IN_PROCESSOR, TEAM_REVIEWER),
|
||||
offer(DISMISS, ANYONE_WHO_SEES));
|
||||
// No known fix to declare, so a plain retry leads: these are often one-offs.
|
||||
global(OPEN_IN_TOOL, OWNER, SECONDARY),
|
||||
global(VIEW_FILE, OWNER, SECONDARY),
|
||||
global(VIEW_IN_PROCESSOR, TEAM_REVIEWER, OVERFLOW),
|
||||
global(DISMISS, ANYONE_WHO_SEES, OVERFLOW));
|
||||
|
||||
private static final String KEY_PREFIX = "portal.failures.kind.";
|
||||
private static final String ACTION_KEY_PREFIX = "portal.failures.action.";
|
||||
@@ -98,27 +102,37 @@ public enum FailureKind {
|
||||
this.offers = List.of(offers);
|
||||
}
|
||||
|
||||
/**
|
||||
* One ordered list rather than ids plus parallel maps of audiences and labels, which could
|
||||
* disagree with each other.
|
||||
*
|
||||
* @param labelKeySuffix key under {@code portal.failures.action.}, or null for the generic
|
||||
* label
|
||||
*/
|
||||
private record Offer(FailureActionId id, FailureAudience audience, String labelKeySuffix) {}
|
||||
/** One ordered list, not parallel maps of audiences, slots and labels that could disagree. */
|
||||
private record Offer(
|
||||
FailureActionId id,
|
||||
FailureAudience audience,
|
||||
FailureActionSlot slot,
|
||||
String labelKeySuffix) {}
|
||||
|
||||
/** Declaration order is display order. */
|
||||
private static Offer offer(FailureActionId id, FailureAudience audience) {
|
||||
return new Offer(id, audience, null);
|
||||
/** The action that fixes this kind. One per kind: needing two would make it two kinds. */
|
||||
private static Offer resolution(FailureActionId id, FailureAudience audience) {
|
||||
return new Offer(id, audience, FailureActionSlot.RESOLUTION, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* As {@link #offer(FailureActionId, FailureAudience)}, but labelled by this kind's own wording
|
||||
* where the shared one reads badly.
|
||||
*/
|
||||
private static Offer offer(
|
||||
/** As {@link #resolution(FailureActionId, FailureAudience)}, with this kind's own wording. */
|
||||
private static Offer resolution(
|
||||
FailureActionId id, FailureAudience audience, String labelKeySuffix) {
|
||||
return new Offer(id, audience, labelKeySuffix);
|
||||
return new Offer(id, audience, FailureActionSlot.RESOLUTION, labelKeySuffix);
|
||||
}
|
||||
|
||||
/** Not this kind's fix: an offer any kind can make, with the shared wording. */
|
||||
private static Offer global(
|
||||
FailureActionId id, FailureAudience audience, FailureActionSlot slot) {
|
||||
return new Offer(id, audience, slot, null);
|
||||
}
|
||||
|
||||
/** As above, with this kind's own wording where the shared one reads badly. */
|
||||
private static Offer global(
|
||||
FailureActionId id,
|
||||
FailureAudience audience,
|
||||
FailureActionSlot slot,
|
||||
String labelKeySuffix) {
|
||||
return new Offer(id, audience, slot, labelKeySuffix);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,21 +171,25 @@ public enum FailureKind {
|
||||
return offers.stream().map(Offer::id).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* What this kind offers, in declaration order, each with its label resolved. What a review
|
||||
* surface reads, so it never has to ask two separate questions about one offer.
|
||||
*/
|
||||
/** What this kind offers, in declaration order, each with label and placement resolved. */
|
||||
public List<OfferedAction> getOfferedActions() {
|
||||
return offers.stream()
|
||||
.map(
|
||||
offer ->
|
||||
new OfferedAction(
|
||||
offer.id(), labelKeyFor(offer.id()), offer.audience()))
|
||||
offer.id(),
|
||||
labelKeyFor(offer.id()),
|
||||
offer.audience(),
|
||||
offer.slot()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** One action as a kind declares it: what to call it and who it is for. */
|
||||
public record OfferedAction(FailureActionId id, String labelKey, FailureAudience audience) {}
|
||||
/** One action as a kind declares it: what to call it, who it is for, where it wants to sit. */
|
||||
public record OfferedAction(
|
||||
FailureActionId id,
|
||||
String labelKey,
|
||||
FailureAudience audience,
|
||||
FailureActionSlot slot) {}
|
||||
|
||||
/** Whether this kind offers {@code action}. The dispatch guard: see {@code FailureActionId}. */
|
||||
public boolean declares(FailureActionId action) {
|
||||
|
||||
+5
-4
@@ -64,16 +64,17 @@ public interface FileRunEventRepository extends JpaRepository<FileRunEventEntity
|
||||
int fold(@Param("id") String id, @Param("now") Instant now, @Param("detail") String detail);
|
||||
|
||||
/**
|
||||
* Reopen a resolved incident whose failure has recurred. Guarded on the current status so only
|
||||
* {@code RESOLVED} flips; a concurrent dismiss is never overwritten back to {@code NEW}.
|
||||
* A recurrence reopens {@code RESOLVED} (the fix did not hold) and {@code FILE_REMOVED} (the
|
||||
* document is back). Guarded, so a reviewer's {@code DISMISSED} is never overwritten.
|
||||
*/
|
||||
@Modifying(clearAutomatically = true)
|
||||
@Transactional
|
||||
@Query(
|
||||
"update FileRunEventEntity e set"
|
||||
+ " e.status = stirling.software.proprietary.failure.FileRunEventStatus.NEW,"
|
||||
+ " e.statusActor = null, e.statusAt = null where e.id = :id and e.status ="
|
||||
+ " stirling.software.proprietary.failure.FileRunEventStatus.RESOLVED")
|
||||
+ " e.statusActor = null, e.statusAt = null where e.id = :id and e.status in"
|
||||
+ " (stirling.software.proprietary.failure.FileRunEventStatus.RESOLVED,"
|
||||
+ " stirling.software.proprietary.failure.FileRunEventStatus.FILE_REMOVED)")
|
||||
int reopenIfResolved(@Param("id") String id);
|
||||
|
||||
/**
|
||||
|
||||
+53
-4
@@ -1,5 +1,9 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -171,6 +175,18 @@ public class FileRunEventService {
|
||||
return action.execute(event, inputs == null ? Map.of() : inputs, currentActor());
|
||||
}
|
||||
|
||||
/** Mark an incident resolved after a client's own retry worked. Idempotent. */
|
||||
public FileRunEvent resolve(String eventId) {
|
||||
FileRunEvent event = requireVisible(eventId);
|
||||
// No terminal pre-check: the store's guarded UPDATE decides, rather than racing a read.
|
||||
return store.applyStatusOnce(
|
||||
event.id(),
|
||||
event.teamId(),
|
||||
FileRunEventStatus.RESOLVED,
|
||||
currentActor(),
|
||||
FileRunEventStatus.open());
|
||||
}
|
||||
|
||||
/** "No such event" rather than a refusal, so trying does not confirm a colleague's exists. */
|
||||
private FileRunEvent requireVisible(String eventId) {
|
||||
ReadScope scope = readScope();
|
||||
@@ -222,7 +238,8 @@ public class FileRunEventService {
|
||||
boolean unattended,
|
||||
boolean documentless) {
|
||||
String reason = disabledReasonFor(offer.audience(), closed, unattended, documentless);
|
||||
return new AvailableAction(offer.id(), offer.labelKey(), reason == null, reason);
|
||||
return new AvailableAction(
|
||||
offer.id(), offer.labelKey(), offer.slot(), reason == null, reason);
|
||||
}
|
||||
|
||||
/** Closed wins over everything, then the owner-only reasons, most specific first. */
|
||||
@@ -251,11 +268,38 @@ public class FileRunEventService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Login disabled has no roles, so its one operator triages everything. */
|
||||
private boolean reviewsTeam() {
|
||||
/** Whether the caller triages the team's incidents, not only their own. Login disabled: all. */
|
||||
public boolean reviewsTeam() {
|
||||
return !enforced() || policyManagementAuthority.canEditPolicies();
|
||||
}
|
||||
|
||||
/**
|
||||
* An opaque, stable discriminator for the calling viewer, for a client scoping per-browser read
|
||||
* state. Hashed rather than the username itself: a client only needs to tell one viewer from
|
||||
* another, and the value ends up in that browser's own storage.
|
||||
*
|
||||
* <p>{@code "anonymous"} with login disabled, where the one operator is every viewer.
|
||||
*/
|
||||
public String viewerKey() {
|
||||
String actor = currentActor();
|
||||
return actor == null || actor.isBlank() ? "anonymous" : sha256Prefix(actor);
|
||||
}
|
||||
|
||||
/** First 8 bytes of SHA-256 as hex: stable, one-way, and collision-safe enough to key on. */
|
||||
private static String sha256Prefix(String value) {
|
||||
try {
|
||||
byte[] digest =
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(digest, 0, 8);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
// Every JVM ships SHA-256; a constant here would silently merge two viewers' read
|
||||
// state, so the caller gets no key and the client falls back to showing everything.
|
||||
log.warn("SHA-256 unavailable, so notifications cannot be scoped to a viewer", e);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private FailureActionId parseActionId(String actionId) {
|
||||
for (FailureActionId candidate : FailureActionId.values()) {
|
||||
if (candidate.name().equals(actionId)) {
|
||||
@@ -326,6 +370,11 @@ public class FileRunEventService {
|
||||
return applicationProperties.getSecurity().isEnableLogin();
|
||||
}
|
||||
|
||||
/** One action offered to one caller, availability resolved. */
|
||||
public record AvailableAction(
|
||||
FailureActionId id, String labelKey, boolean enabled, String disabledReasonKey) {}
|
||||
FailureActionId id,
|
||||
String labelKey,
|
||||
FailureActionSlot slot,
|
||||
boolean enabled,
|
||||
String disabledReasonKey) {}
|
||||
}
|
||||
|
||||
+3
-7
@@ -3,10 +3,7 @@ package stirling.software.proprietary.failure;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Disposition of one recorded failure. {@code RESOLVED} is declared but not set yet (it becomes
|
||||
* system-set later); the rollup already defines what a repeat means for it, which is to reopen.
|
||||
*/
|
||||
/** Disposition of one recorded failure. {@code RESOLVED} is system-set; a repeat reopens it. */
|
||||
public enum FileRunEventStatus {
|
||||
NEW(false),
|
||||
ACKNOWLEDGED(false),
|
||||
@@ -14,9 +11,8 @@ public enum FileRunEventStatus {
|
||||
RESOLVED(true),
|
||||
|
||||
/**
|
||||
* The document this incident was about was deleted from its owner's editor, so there is nothing
|
||||
* left to act on. Distinct from {@code DISMISSED}, which is a reviewer's decision, and from
|
||||
* {@code RESOLVED}, which reopens on recurrence: this one cannot recur, the file is gone.
|
||||
* The document was deleted, so there is nothing left to act on. A recurrence reopens it like
|
||||
* {@code RESOLVED}: a fresh failure is proof the document is back.
|
||||
*/
|
||||
FILE_REMOVED(true);
|
||||
|
||||
|
||||
+4
-2
@@ -61,14 +61,15 @@ public record FileRunEventView(
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code defaultLabel} and {@code execution} let a client render and route an action it was
|
||||
* never built with. Declaration order is display order.
|
||||
* {@code defaultLabel} and {@code execution} let a client render an action it was never built
|
||||
* with; {@code slot} is placement intent. See {@link FailureActionSlot}.
|
||||
*/
|
||||
public record ActionView(
|
||||
String id,
|
||||
String labelKey,
|
||||
String defaultLabel,
|
||||
FailureActionId.Execution execution,
|
||||
FailureActionSlot slot,
|
||||
boolean enabled,
|
||||
String disabledReasonKey) {
|
||||
|
||||
@@ -78,6 +79,7 @@ public record FileRunEventView(
|
||||
action.labelKey(),
|
||||
action.id().getDefaultLabel(),
|
||||
action.id().getExecution(),
|
||||
action.slot(),
|
||||
action.enabled(),
|
||||
action.disabledReasonKey());
|
||||
}
|
||||
|
||||
+35
-4
@@ -2,10 +2,14 @@ package stirling.software.proprietary.notification;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -13,9 +17,11 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.failure.FailureActionException;
|
||||
|
||||
/**
|
||||
* Open to any authenticated user, unlike the failure endpoints it draws on: each source scopes its
|
||||
* own rows. Read-only, because every action a notification offers runs on the client's own device.
|
||||
* Open to any authenticated user: each source scopes its own rows. Every action runs on the
|
||||
* client's own device, so the only write is it reporting a fix.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/notifications")
|
||||
@@ -40,9 +46,34 @@ public class NotificationController {
|
||||
+ " to mark read here yet: the client tracks what it has shown.")
|
||||
public NotificationsResponse list(@RequestParam(required = false) Integer limit) {
|
||||
int capped = Math.min(limit == null ? DEFAULT_LIMIT : Math.max(1, limit), MAX_LIMIT);
|
||||
return new NotificationsResponse(notifications.list(capped));
|
||||
return new NotificationsResponse(
|
||||
notifications.list(capped),
|
||||
notifications.callerReviewsTeam(),
|
||||
notifications.callerViewerKey());
|
||||
}
|
||||
|
||||
@PostMapping("/{notificationId}/resolved")
|
||||
@Operation(
|
||||
summary = "Record that a client-side retry fixed what a notification was about",
|
||||
description =
|
||||
"Takes the prefixed notification id, not the producing row's id. Not an action:"
|
||||
+ " nobody is offered a resolve button, and a recurrence brings the"
|
||||
+ " notification back.")
|
||||
public NotificationView resolved(@PathVariable String notificationId) {
|
||||
try {
|
||||
return notifications.resolve(notificationId);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage(), e);
|
||||
} catch (FailureActionException e) {
|
||||
throw new ResponseStatusException(
|
||||
FailureActionException.statusOf(e.getReason()), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Wrapped so paging or a total can be added without breaking clients. */
|
||||
public record NotificationsResponse(List<NotificationView> notifications) {}
|
||||
public record NotificationsResponse(
|
||||
List<NotificationView> notifications,
|
||||
boolean viewerReviewsTeam,
|
||||
/** Opaque; the client scopes its own read state on it. Empty means "cannot scope". */
|
||||
String viewerKey) {}
|
||||
}
|
||||
|
||||
+40
-2
@@ -20,9 +20,47 @@ public class NotificationService {
|
||||
|
||||
private final FileRunEventService fileRunEvents;
|
||||
|
||||
/** Newest first, and only open failures: one already dealt with is not news. */
|
||||
/**
|
||||
* Newest first, and only open failures about a document: one already dealt with is not news,
|
||||
* and a row naming no file has nothing the bell can offer beyond saying so.
|
||||
*
|
||||
* <p>Filtered on the named file rather than the kind's scope, because a RUN-scoped kind still
|
||||
* names one when the editor reported it: a failed tool run belongs here. Applied after the
|
||||
* limit, so a page can come back short while unattributed rows exist - the review surface is
|
||||
* where those are meant to be read, and it lists them unfiltered.
|
||||
*/
|
||||
public List<NotificationView> list(int limit) {
|
||||
return fileRunEvents.list(null, null, limit).stream().map(this::fromFailure).toList();
|
||||
return fileRunEvents.list(null, null, limit).stream()
|
||||
.filter(event -> event.fileId() != null && !event.fileId().isBlank())
|
||||
.map(this::fromFailure)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** Whether the caller sees the whole team's incidents rather than only their own. */
|
||||
public boolean callerReviewsTeam() {
|
||||
return fileRunEvents.reviewsTeam();
|
||||
}
|
||||
|
||||
/** Opaque and stable, so a shared browser can keep one viewer's read state off another's. */
|
||||
public String callerViewerKey() {
|
||||
return fileRunEvents.viewerKey();
|
||||
}
|
||||
|
||||
/** Takes the prefixed id, so the bell cannot reach a failure endpoint even by accident. */
|
||||
public NotificationView resolve(String notificationId) {
|
||||
NotificationSource.QualifiedId qualified = qualify(notificationId);
|
||||
return switch (qualified.source()) {
|
||||
case FAILURE -> fromFailure(fileRunEvents.resolve(qualified.rowId()));
|
||||
};
|
||||
}
|
||||
|
||||
/** The source and row id behind a notification id, refusing anything that is not one. */
|
||||
private static NotificationSource.QualifiedId qualify(String notificationId) {
|
||||
return NotificationSource.parse(notificationId)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalArgumentException(
|
||||
"Not a notification id: " + notificationId));
|
||||
}
|
||||
|
||||
/** Prefixes the row id on the way out, so it is never sent bare. */
|
||||
|
||||
+22
@@ -1,6 +1,8 @@
|
||||
package stirling.software.proprietary.notification;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Which subsystem produced a notification. Every id is prefixed with it, so a client never holds
|
||||
@@ -18,4 +20,24 @@ public enum NotificationSource {
|
||||
public String qualify(String sourceRowId) {
|
||||
return prefix() + sourceRowId;
|
||||
}
|
||||
|
||||
/** Empty rather than throwing for an unprefixed or unknown id: both arrive from clients. */
|
||||
public static Optional<QualifiedId> parse(String notificationId) {
|
||||
if (notificationId == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
int separator = notificationId.indexOf(SEPARATOR);
|
||||
if (separator <= 0 || separator == notificationId.length() - 1) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String prefix = notificationId.substring(0, separator);
|
||||
String rowId = notificationId.substring(separator + 1);
|
||||
return Arrays.stream(values())
|
||||
.filter(source -> source.name().equalsIgnoreCase(prefix))
|
||||
.findFirst()
|
||||
.map(source -> new QualifiedId(source, rowId));
|
||||
}
|
||||
|
||||
/** A notification id split into the source that owns it and that source's own row id. */
|
||||
public record QualifiedId(NotificationSource source, String rowId) {}
|
||||
}
|
||||
|
||||
+11
-1
@@ -336,6 +336,15 @@ public class PolicyController {
|
||||
* nothing to check.
|
||||
*/
|
||||
private void requireAccessibleOutput(Policy policy) {
|
||||
// An editor policy hands its results back to the workspace the file came from. A stored
|
||||
// destination would send the run to a folder or bucket instead, leaving the editor's copy
|
||||
// untouched - and the editor's import would then have nothing to collect.
|
||||
if (policy.editor().allowed() && !policy.outputIds().isEmpty()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"An editor policy delivers back to the editor and can't also have a"
|
||||
+ " destination");
|
||||
}
|
||||
for (String outputId : policy.outputIds()) {
|
||||
Source destination =
|
||||
sourceStore
|
||||
@@ -393,7 +402,8 @@ public class PolicyController {
|
||||
policy.steps(),
|
||||
policy.output(),
|
||||
policy.outputIds(),
|
||||
teamId);
|
||||
teamId,
|
||||
policy.editor());
|
||||
}
|
||||
|
||||
/** Output secrets never leave the server: reads return the redaction sentinel instead. */
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package stirling.software.proprietary.policy.model;
|
||||
|
||||
/**
|
||||
* How a policy participates in the editor: it fires in the browser as each file passes through,
|
||||
* rather than being swept from a stored {@code Source} on a trigger.
|
||||
*
|
||||
* <p>An object rather than a bare flag so the moment it fires ({@code runOn}) travels with the
|
||||
* decision, and so later editor-only settings have somewhere to live.
|
||||
*
|
||||
* @param allowed whether the editor may run this policy at all
|
||||
* @param runOn which moment it fires on: {@code "upload"} or {@code "export"}
|
||||
*/
|
||||
public record EditorConfig(boolean allowed, String runOn) {
|
||||
|
||||
public static final String UPLOAD = "upload";
|
||||
public static final String EXPORT = "export";
|
||||
|
||||
public EditorConfig {
|
||||
runOn = EXPORT.equals(runOn) ? EXPORT : UPLOAD;
|
||||
}
|
||||
|
||||
/** Not an editor policy: swept server-side, or run only on demand. */
|
||||
public static EditorConfig disabled() {
|
||||
return new EditorConfig(false, UPLOAD);
|
||||
}
|
||||
|
||||
public static EditorConfig onUpload() {
|
||||
return new EditorConfig(true, UPLOAD);
|
||||
}
|
||||
|
||||
public static EditorConfig onExport() {
|
||||
return new EditorConfig(true, EXPORT);
|
||||
}
|
||||
}
|
||||
+32
-4
@@ -1,6 +1,7 @@
|
||||
package stirling.software.proprietary.policy.model;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* A stored automation: ordered tool steps, input bindings, and output destinations.
|
||||
@@ -24,13 +25,29 @@ public record Policy(
|
||||
List<PipelineStep> steps,
|
||||
OutputSpec output,
|
||||
List<String> outputIds,
|
||||
Long teamId) {
|
||||
Long teamId,
|
||||
EditorConfig editor) {
|
||||
|
||||
public Policy {
|
||||
inputs = inputs == null ? List.of() : List.copyOf(inputs);
|
||||
steps = steps == null ? List.of() : steps;
|
||||
output = output == null ? OutputSpec.inline() : output;
|
||||
outputIds = outputIds == null ? List.of() : List.copyOf(outputIds);
|
||||
editor = editor == null ? EditorConfig.disabled() : editor;
|
||||
}
|
||||
|
||||
/** Without editor participation: a swept or on-demand policy. */
|
||||
public Policy(
|
||||
String id,
|
||||
String name,
|
||||
String owner,
|
||||
boolean enabled,
|
||||
List<PipelineInput> inputs,
|
||||
List<PipelineStep> steps,
|
||||
OutputSpec output,
|
||||
List<String> outputIds,
|
||||
Long teamId) {
|
||||
this(id, name, owner, enabled, inputs, steps, output, outputIds, teamId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,6 +87,14 @@ public record Policy(
|
||||
return inputs.stream().map(PipelineInput::sourceId).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* The moment this policy fires in the editor ("upload" / "export"), or empty when the editor
|
||||
* does not run it. Legacy blobs are lifted onto {@link EditorConfig} when they are read.
|
||||
*/
|
||||
public Optional<String> editorRunOn() {
|
||||
return editor.allowed() ? Optional.of(editor.runOn()) : Optional.empty();
|
||||
}
|
||||
|
||||
/** The distinct trigger types configured across this policy's inputs (manual inputs aside). */
|
||||
public List<String> triggerTypes() {
|
||||
return inputs.stream()
|
||||
@@ -82,17 +107,20 @@ public record Policy(
|
||||
|
||||
/** A copy with the inline output replaced (e.g. resolved for the engine, or migrated). */
|
||||
public Policy withOutput(OutputSpec resolved) {
|
||||
return new Policy(id, name, owner, enabled, inputs, steps, resolved, outputIds, teamId);
|
||||
return new Policy(
|
||||
id, name, owner, enabled, inputs, steps, resolved, outputIds, teamId, editor);
|
||||
}
|
||||
|
||||
/** A copy under a different owner (e.g. moving a seed off a placeholder name). */
|
||||
public Policy withOwner(String newOwner) {
|
||||
return new Policy(id, name, newOwner, enabled, inputs, steps, output, outputIds, teamId);
|
||||
return new Policy(
|
||||
id, name, newOwner, enabled, inputs, steps, output, outputIds, teamId, editor);
|
||||
}
|
||||
|
||||
/** A copy referencing the given saved output destinations. */
|
||||
public Policy withOutputIds(List<String> newOutputIds) {
|
||||
return new Policy(id, name, owner, enabled, inputs, steps, output, newOutputIds, teamId);
|
||||
return new Policy(
|
||||
id, name, owner, enabled, inputs, steps, output, newOutputIds, teamId, editor);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+5
-1
@@ -114,10 +114,14 @@ public class PolicyOverviewService {
|
||||
/**
|
||||
* Summarise a policy's triggers for the overview row: "manual" when no input is triggered,
|
||||
* otherwise the distinct trigger types across its inputs (e.g. "folder-watch, schedule").
|
||||
*
|
||||
* <p>An editor policy has no wire input to trigger, but it is not manual either - it fires in
|
||||
* the editor on every upload or export, so it reports that rather than reading as on-demand.
|
||||
*/
|
||||
private static String triggerSummary(Policy policy) {
|
||||
List<String> types = policy.triggerTypes();
|
||||
return types.isEmpty() ? "manual" : String.join(", ", types);
|
||||
if (!types.isEmpty()) return String.join(", ", types);
|
||||
return policy.editorRunOn().map(runOn -> "editor-" + runOn).orElse("manual");
|
||||
}
|
||||
|
||||
private static String outputSummary(OutputSpec output) {
|
||||
|
||||
+6
-3
@@ -14,6 +14,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.model.TeamCreatedEvent;
|
||||
import stirling.software.proprietary.policy.model.EditorConfig;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.PipelineStep;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
@@ -98,9 +99,8 @@ public class DefaultClassificationPolicySeeder {
|
||||
static Policy defaultPolicy(Long teamId) {
|
||||
Map<String, Object> options = new HashMap<>();
|
||||
options.put("categoryId", CATEGORY);
|
||||
options.put("runOn", "upload");
|
||||
options.put("mode", "new_version");
|
||||
options.put("sources", List.of("editor"));
|
||||
options.put("sources", List.of());
|
||||
options.put("scopeTypes", List.of());
|
||||
options.put("reviewerEmail", "");
|
||||
return new Policy(
|
||||
@@ -113,6 +113,9 @@ public class DefaultClassificationPolicySeeder {
|
||||
List.of(),
|
||||
List.of(new PipelineStep(CLASSIFY_ENDPOINT, Map.of())),
|
||||
new OutputSpec("inline", options),
|
||||
teamId);
|
||||
List.of(),
|
||||
teamId,
|
||||
// Classification runs in the editor on every upload.
|
||||
EditorConfig.onUpload());
|
||||
}
|
||||
}
|
||||
|
||||
+4
-6
@@ -107,14 +107,12 @@ public class SourceOverviewService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a policy runs from the editor. Editor membership is carried in the policy's output
|
||||
* metadata ({@code output.options.sources}) - a client-side list the editor writes when a
|
||||
* policy targets it - rather than as a persisted {@code sourceId}, because the editor is
|
||||
* virtual and has no stored source to reference.
|
||||
* Whether a policy runs from the editor. Read from the policy's first-class {@link
|
||||
* stirling.software.proprietary.policy.model.EditorConfig}, never inferred from a sources list
|
||||
* (the editor is not a real source).
|
||||
*/
|
||||
private static boolean runsFromEditor(Policy policy) {
|
||||
Object sources = policy.output().options().get("sources");
|
||||
return sources instanceof List<?> list && list.contains(EditorSource.ID);
|
||||
return policy.editor().allowed();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2
-1
@@ -38,7 +38,8 @@ public class InProcessPolicyStore implements PolicyStore {
|
||||
policy.steps(),
|
||||
policy.output(),
|
||||
policy.outputIds(),
|
||||
policy.teamId());
|
||||
policy.teamId(),
|
||||
policy.editor());
|
||||
policies.put(id, stored);
|
||||
// Existing policy keeps its position; a new one appends to the end of its team's queue.
|
||||
sortOrders.computeIfAbsent(id, key -> nextSortOrder(stored.teamId()));
|
||||
|
||||
+65
-2
@@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.store;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -11,8 +12,10 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.policy.model.EditorConfig;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.model.PolicyBinding;
|
||||
import stirling.software.proprietary.policy.source.EditorSource;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
@@ -48,7 +51,8 @@ public class JpaPolicyStore implements PolicyStore {
|
||||
policy.steps(),
|
||||
policy.output(),
|
||||
policy.outputIds(),
|
||||
policy.teamId());
|
||||
policy.teamId(),
|
||||
policy.editor());
|
||||
|
||||
PolicyEntity entity = new PolicyEntity();
|
||||
entity.setId(id);
|
||||
@@ -148,7 +152,9 @@ public class JpaPolicyStore implements PolicyStore {
|
||||
// One unreadable row must never abort a bulk read or crash startup.
|
||||
private Optional<Policy> toPolicy(PolicyEntity entity) {
|
||||
try {
|
||||
JsonNode node = upgradeLegacyShape(objectMapper.readTree(entity.getPolicyJson()));
|
||||
JsonNode node =
|
||||
liftEditorConfig(
|
||||
upgradeLegacyShape(objectMapper.readTree(entity.getPolicyJson())));
|
||||
return Optional.of(objectMapper.treeToValue(node, Policy.class));
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
@@ -191,4 +197,61 @@ public class JpaPolicyStore implements PolicyStore {
|
||||
obj.remove("sourceIds");
|
||||
return obj;
|
||||
}
|
||||
|
||||
/** Categories whose editor moment defaulted to export before it was stored (see runOn.ts). */
|
||||
private static final Set<String> EXPORT_BY_DEFAULT = Set.of("security");
|
||||
|
||||
/**
|
||||
* Derive {@code editor} for a blob written before editor participation had its own field, from
|
||||
* its {@code output.options}: allowed when {@code sources} lists {@code "editor"}, or - for a
|
||||
* catalogue policy - when there is no {@code sources} list at all (an unnarrowed catalogue
|
||||
* policy runs in the editor).
|
||||
*
|
||||
* <p>Runs on every read, deliberately outside {@link #upgradeLegacyShape}'s early return: a
|
||||
* blob written after triggers moved onto {@code inputs} but before this field existed still
|
||||
* needs lifting, and that early return would skip exactly those rows.
|
||||
*/
|
||||
private JsonNode liftEditorConfig(JsonNode root) {
|
||||
if (!(root instanceof ObjectNode obj) || obj.hasNonNull("editor")) {
|
||||
return root;
|
||||
}
|
||||
JsonNode options = obj.path("output").path("options");
|
||||
String categoryId = text(options, "categoryId");
|
||||
JsonNode sources = options.get("sources");
|
||||
boolean listed = sources != null && sources.isArray() && !sources.isEmpty();
|
||||
boolean allowed;
|
||||
if (listed) {
|
||||
// An explicit scope list decides: only the editor's own id puts it on the editor.
|
||||
allowed = false;
|
||||
for (JsonNode source : sources) {
|
||||
if (source.isValueNode() && EditorSource.ID.equals(source.asString())) {
|
||||
allowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No list: a catalogue policy ran in the editor by default, but a builder pipeline
|
||||
// (no category) could not reach the editor at all, so silence is not consent there.
|
||||
allowed = !categoryId.isBlank();
|
||||
}
|
||||
ObjectNode editor = objectMapper.createObjectNode();
|
||||
editor.put("allowed", allowed);
|
||||
editor.put("runOn", legacyRunOn(options, categoryId));
|
||||
obj.set("editor", editor);
|
||||
return obj;
|
||||
}
|
||||
|
||||
/** The stored moment, or the category default the client applied when none was stored. */
|
||||
private static String legacyRunOn(JsonNode options, String categoryId) {
|
||||
String stored = text(options, "runOn");
|
||||
if (EditorConfig.EXPORT.equals(stored) || EditorConfig.UPLOAD.equals(stored)) {
|
||||
return stored;
|
||||
}
|
||||
return EXPORT_BY_DEFAULT.contains(categoryId) ? EditorConfig.EXPORT : EditorConfig.UPLOAD;
|
||||
}
|
||||
|
||||
private static String text(JsonNode parent, String field) {
|
||||
JsonNode node = parent.path(field);
|
||||
return node.isValueNode() ? node.asString() : "";
|
||||
}
|
||||
}
|
||||
|
||||
+43
@@ -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()) {
|
||||
|
||||
-2
@@ -357,8 +357,6 @@ class ConnectServiceTest {
|
||||
assertThat(status.authorizeUrl()).isEqualTo("https://app.example.com/link?request=req-1");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/** A start with nothing but the reconstructed request URL, as a headless caller would send. */
|
||||
private static ConnectService.CallbackHint fromRequest(String derivedBaseUrl) {
|
||||
return new ConnectService.CallbackHint(null, null, derivedBaseUrl);
|
||||
|
||||
+1
@@ -43,6 +43,7 @@ class CheckConstrainedEnumsTest {
|
||||
assertThat(persisted)
|
||||
.doesNotContain(
|
||||
FailureAudience.class,
|
||||
FailureActionSlot.class,
|
||||
FailureActionId.class,
|
||||
FailureActionId.Execution.class,
|
||||
Ownership.class);
|
||||
|
||||
+37
-37
@@ -1,6 +1,9 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static stirling.software.proprietary.failure.FailureActionSlot.OVERFLOW;
|
||||
import static stirling.software.proprietary.failure.FailureActionSlot.RESOLUTION;
|
||||
import static stirling.software.proprietary.failure.FailureActionSlot.SECONDARY;
|
||||
import static stirling.software.proprietary.failure.FailureAudience.ANYONE_WHO_SEES;
|
||||
import static stirling.software.proprietary.failure.FailureAudience.OWNER;
|
||||
import static stirling.software.proprietary.failure.FailureAudience.TEAM_REVIEWER;
|
||||
@@ -34,9 +37,12 @@ class FailureKindTest {
|
||||
|
||||
/** In full, so a declaration pairing the right action with the wrong audience cannot pass. */
|
||||
private static FailureKind.OfferedAction offered(
|
||||
FailureActionId id, FailureAudience audience, String labelKeySuffix) {
|
||||
FailureActionId id,
|
||||
FailureAudience audience,
|
||||
FailureActionSlot slot,
|
||||
String labelKeySuffix) {
|
||||
return new FailureKind.OfferedAction(
|
||||
id, "portal.failures.action." + labelKeySuffix, audience);
|
||||
id, "portal.failures.action." + labelKeySuffix, audience, slot);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -70,27 +76,6 @@ class FailureKindTest {
|
||||
assertThat(kind.getId()).matches("^[A-Z][A-Z0-9_]*$");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(FailureKind.class)
|
||||
void declaresItsActionsInTheSameOrderAsEveryOtherKind(FailureKind kind) {
|
||||
// Declaration order is display order and the first usable offer is the row's primary,
|
||||
// so
|
||||
// two kinds disagreeing would flip the solid button between rows.
|
||||
List<FailureActionId> ranking =
|
||||
List.of(
|
||||
FailureActionId.VIEW_FILE,
|
||||
FailureActionId.VIEW_IN_PROCESSOR,
|
||||
FailureActionId.DISMISS);
|
||||
|
||||
List<FailureActionId> declared = kind.getActions();
|
||||
assertThat(ranking)
|
||||
.as("%s declares an action the shared ranking does not rank", kind.getId())
|
||||
.containsAll(declared);
|
||||
assertThat(declared)
|
||||
.as("%s declares its actions out of the shared order", kind.getId())
|
||||
.isEqualTo(ranking.stream().filter(declared::contains).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void idsAreUnique() {
|
||||
Set<String> ids = new HashSet<>();
|
||||
@@ -121,12 +106,13 @@ class FailureKindTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(FailureKind.class)
|
||||
void everyOfferSaysWhoItIsFor(FailureKind kind) {
|
||||
// Read per row to decide what a caller is shown, so a null would leak a button.
|
||||
void everyOfferSaysWhoItIsForAndWhereItGoes(FailureKind kind) {
|
||||
// Both decide what a caller is shown, so a missing one places a button by accident.
|
||||
for (FailureKind.OfferedAction offer : kind.getOfferedActions()) {
|
||||
assertThat(offer.audience())
|
||||
.as("%s offers %s", kind.getId(), offer.id())
|
||||
.isNotNull();
|
||||
assertThat(offer.slot()).as("%s offers %s", kind.getId(), offer.id()).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +124,17 @@ class FailureKindTest {
|
||||
assertThat(kind.getActions()).doesNotHaveDuplicates();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(FailureKind.class)
|
||||
void declaresAtMostOneResolution(FailureKind kind) {
|
||||
// Two things that both claim to fix it is a sign of two kinds wearing one id.
|
||||
assertThat(
|
||||
kind.getOfferedActions().stream()
|
||||
.filter(offer -> offer.slot() == FailureActionSlot.RESOLUTION)
|
||||
.toList())
|
||||
.hasSizeLessThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void noTwoKindsClaimTheSameErrorCode() {
|
||||
// Computed independently of duplicateErrorCodes(), then checked against it: the boot
|
||||
@@ -232,16 +229,18 @@ class FailureKindTest {
|
||||
class Unknown {
|
||||
|
||||
@Test
|
||||
void offersItsOwnerTheirDocumentAndTheRunToWhoeverReviews() {
|
||||
// Nothing here is known to be fixable, so the offers are just the places to look.
|
||||
void offersARetryToItsOwnerAndTheRunToWhoeverReviews() {
|
||||
// No known fix, so no resolution; a retry is still worth offering for a one-off.
|
||||
assertThat(FailureKind.UNKNOWN.getOfferedActions())
|
||||
.containsExactly(
|
||||
offered(FailureActionId.VIEW_FILE, OWNER, "viewFile"),
|
||||
offered(FailureActionId.OPEN_IN_TOOL, OWNER, SECONDARY, "openInTool"),
|
||||
offered(FailureActionId.VIEW_FILE, OWNER, SECONDARY, "viewFile"),
|
||||
offered(
|
||||
FailureActionId.VIEW_IN_PROCESSOR,
|
||||
TEAM_REVIEWER,
|
||||
OVERFLOW,
|
||||
"viewInProcessor"),
|
||||
offered(FailureActionId.DISMISS, ANYONE_WHO_SEES, "dismiss"));
|
||||
offered(FailureActionId.DISMISS, ANYONE_WHO_SEES, OVERFLOW, "dismiss"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -294,16 +293,19 @@ class FailureKindTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void offersTheDocumentToItsOwnerAndTheRunToItsReviewer() {
|
||||
// The point of the audiences: only the owner holds the document.
|
||||
void aKindWithSomethingToFixOffersTheFixToItsOwnerAndTheRunToItsReviewer() {
|
||||
// Only the owner has the password, so a reviewer is offered the run and a dismiss.
|
||||
assertThat(FailureKind.INPUT_PASSWORD_PROTECTED.getOfferedActions())
|
||||
.containsExactly(
|
||||
offered(FailureActionId.VIEW_FILE, OWNER, "viewFile"),
|
||||
offered(FailureActionId.DECRYPT, OWNER, RESOLUTION, "decrypt"),
|
||||
offered(FailureActionId.VIEW_FILE, OWNER, SECONDARY, "viewFile"),
|
||||
offered(
|
||||
FailureActionId.VIEW_IN_PROCESSOR,
|
||||
TEAM_REVIEWER,
|
||||
OVERFLOW,
|
||||
"viewInProcessor"),
|
||||
offered(FailureActionId.DISMISS, ANYONE_WHO_SEES, "dismiss"));
|
||||
offered(FailureActionId.OPEN_IN_TOOL, OWNER, OVERFLOW, "openInTool"),
|
||||
offered(FailureActionId.DISMISS, ANYONE_WHO_SEES, OVERFLOW, "dismiss"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -333,10 +335,8 @@ class FailureKindTest {
|
||||
assertThat(FailureKind.UNKNOWN.labelKeyFor(FailureActionId.DISMISS))
|
||||
.isEqualTo(FailureKind.genericLabelKey(FailureActionId.DISMISS))
|
||||
.isEqualTo("portal.failures.action.dismiss");
|
||||
assertThat(
|
||||
FailureKind.INPUT_PASSWORD_PROTECTED.labelKeyFor(
|
||||
FailureActionId.VIEW_IN_PROCESSOR))
|
||||
.isEqualTo("portal.failures.action.viewInProcessor");
|
||||
assertThat(FailureKind.INPUT_PASSWORD_PROTECTED.labelKeyFor(FailureActionId.DECRYPT))
|
||||
.isEqualTo("portal.failures.action.decrypt");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+2
@@ -153,6 +153,7 @@ class FileRunEventControllerTest {
|
||||
action -> {
|
||||
assertThat(action.defaultLabel()).isNotBlank();
|
||||
assertThat(action.execution()).isNotNull();
|
||||
assertThat(action.slot()).isNotNull();
|
||||
})
|
||||
.filteredOn(action -> "VIEW_IN_PROCESSOR".equals(action.id()))
|
||||
.singleElement()
|
||||
@@ -160,6 +161,7 @@ class FileRunEventControllerTest {
|
||||
action -> {
|
||||
assertThat(action.execution())
|
||||
.isEqualTo(FailureActionId.Execution.CLIENT);
|
||||
assertThat(action.slot()).isEqualTo(FailureActionSlot.OVERFLOW);
|
||||
assertThat(action.defaultLabel()).isEqualTo("View in processor");
|
||||
});
|
||||
}
|
||||
|
||||
+1
@@ -130,6 +130,7 @@ class FileRunEventHttpIntegrationTest {
|
||||
assertThat(actions.get(0).get("defaultLabel").asString())
|
||||
.isEqualTo("View in processor");
|
||||
assertThat(actions.get(0).get("execution").asString()).isEqualTo("CLIENT");
|
||||
assertThat(actions.get(0).get("slot").asString()).isEqualTo("OVERFLOW");
|
||||
assertThat(actions.get(0).get("enabled").asBoolean()).isTrue();
|
||||
assertThat(actions.get(0).get("disabledReasonKey").isNull()).isTrue();
|
||||
assertThat(actions.get(1).get("id").asString()).isEqualTo("DISMISS");
|
||||
|
||||
+117
-3
@@ -157,6 +157,103 @@ class FileRunEventServiceTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("resolve")
|
||||
class Resolve {
|
||||
|
||||
@Test
|
||||
void marksTheRowResolvedWhenAClientReportsItsRetryWorked() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
|
||||
FileRunEvent resolved = service.resolve(event.id());
|
||||
|
||||
assertThat(resolved.status()).isEqualTo(FileRunEventStatus.RESOLVED);
|
||||
assertThat(resolved.statusActor()).isEqualTo(ACTOR);
|
||||
assertThat(service.list(null, null, 10)).as("resolved work is not open work").isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isNotAnActionAnyoneCanPress() {
|
||||
// System-set on a client-side retry, so there is no id to dispatch and no button.
|
||||
assertThat(Arrays.stream(FailureActionId.values()).map(Enum::name))
|
||||
.doesNotContain("RESOLVE", "RESOLVED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportingTheSameSuccessTwiceIsNotARefusal() {
|
||||
// A client that retries, succeeds and reports twice is telling the truth twice.
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
Instant first = service.resolve(event.id()).statusAt();
|
||||
|
||||
assertThat(service.resolve(event.id()).statusAt()).isEqualTo(first);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDismissedRowCannotBeResolvedBehindTheReviewersBack() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
service.dispatch(event.id(), "DISMISS", Map.of());
|
||||
|
||||
assertThatThrownBy(() -> service.resolve(event.id()))
|
||||
.isInstanceOf(FailureActionException.class)
|
||||
.extracting(e -> ((FailureActionException) e).getReason())
|
||||
.isEqualTo(FailureActionException.Reason.ALREADY_CLOSED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anotherTeamsRowIsNotFound() {
|
||||
FileRunEvent theirs = given(FailureKind.UNKNOWN, 99L, "f1");
|
||||
|
||||
assertThatThrownBy(() -> service.resolve(theirs.id()))
|
||||
.isInstanceOf(FailureActionException.class)
|
||||
.extracting(e -> ((FailureActionException) e).getReason())
|
||||
.isEqualTo(FailureActionException.Reason.EVENT_NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRecurrenceReopensIt() {
|
||||
// RESOLVED claims one attempt worked, not that the problem is gone for good.
|
||||
service.report(new EditorFailureReport("compress", "E004", List.of("f-1"), "boom"));
|
||||
FileRunEvent event = service.list(null, null, 10).getFirst();
|
||||
service.resolve(event.id());
|
||||
|
||||
service.report(new EditorFailureReport("compress", "E004", List.of("f-1"), "boom"));
|
||||
|
||||
assertThat(service.list(null, null, 10))
|
||||
.singleElement()
|
||||
.extracting(FileRunEvent::status)
|
||||
.isEqualTo(FileRunEventStatus.NEW);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRecurrenceReopensAnIncidentClosedBecauseTheFileWasRemoved() {
|
||||
// A library file comes back under the same id, so without this every repeat folds
|
||||
// into the closed row and the queue never shows the failure again.
|
||||
service.report(new EditorFailureReport("compress", "E001", List.of("f-1"), "boom"));
|
||||
service.forgetFiles(List.of("f-1"));
|
||||
assertThat(service.list(null, null, 10)).isEmpty();
|
||||
|
||||
service.report(new EditorFailureReport("compress", "E001", List.of("f-1"), "boom"));
|
||||
|
||||
assertThat(service.list(null, null, 10))
|
||||
.singleElement()
|
||||
.extracting(FileRunEvent::status)
|
||||
.isEqualTo(FileRunEventStatus.NEW);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRecurrenceLeavesAReviewersDismissalAlone() {
|
||||
// Dismiss is a decision about the incident, not a claim about the document, so it
|
||||
// outlasts a repeat where FILE_REMOVED and RESOLVED do not.
|
||||
service.report(new EditorFailureReport("compress", "E001", List.of("f-1"), "boom"));
|
||||
FileRunEvent event = service.list(null, null, 10).getFirst();
|
||||
service.dispatch(event.id(), "DISMISS", Map.of());
|
||||
|
||||
service.report(new EditorFailureReport("compress", "E001", List.of("f-1"), "boom"));
|
||||
|
||||
assertThat(service.list(null, null, 10)).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("triage never touches the document")
|
||||
class NeverTouchesTheDocument {
|
||||
@@ -352,13 +449,17 @@ class FileRunEventServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void theOwnerIsOfferedTheirDocumentAndNotTheReviewersView() {
|
||||
// The document is theirs to open; the processor view is for whoever reviews the team.
|
||||
void theOwnerIsOfferedTheFixAndNotTheReviewersView() {
|
||||
// The unlock is the owner's to do; the processor view is for whoever reviews.
|
||||
when(authority.canEditPolicies()).thenReturn(false);
|
||||
FileRunEvent mine = givenHitBy(ACTOR, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
|
||||
assertThat(offeredFor(mine))
|
||||
.containsExactly(FailureActionId.VIEW_FILE, FailureActionId.DISMISS);
|
||||
.containsExactly(
|
||||
FailureActionId.DECRYPT,
|
||||
FailureActionId.VIEW_FILE,
|
||||
FailureActionId.OPEN_IN_TOOL,
|
||||
FailureActionId.DISMISS);
|
||||
assertThat(service.availableActions(mine))
|
||||
.allMatch(FileRunEventService.AvailableAction::enabled);
|
||||
}
|
||||
@@ -385,8 +486,10 @@ class FileRunEventServiceTest {
|
||||
|
||||
assertThat(offeredFor(unattended))
|
||||
.containsExactly(
|
||||
FailureActionId.DECRYPT,
|
||||
FailureActionId.VIEW_FILE,
|
||||
FailureActionId.VIEW_IN_PROCESSOR,
|
||||
FailureActionId.OPEN_IN_TOOL,
|
||||
FailureActionId.DISMISS);
|
||||
}
|
||||
|
||||
@@ -499,6 +602,17 @@ class FileRunEventServiceTest {
|
||||
.equals(action.disabledReasonKey()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void carriesTheKindsPlacementIntentForEachOffer() {
|
||||
FileRunEvent mine = givenHitBy(ACTOR, FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "f1");
|
||||
|
||||
assertThat(service.availableActions(mine))
|
||||
.filteredOn(action -> action.id() == FailureActionId.DECRYPT)
|
||||
.singleElement()
|
||||
.extracting(FileRunEventService.AvailableAction::slot)
|
||||
.isEqualTo(FailureActionSlot.RESOLUTION);
|
||||
}
|
||||
|
||||
@Test
|
||||
void carriesTheLabelKeyForEachOffer() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f1");
|
||||
|
||||
+3
-1
@@ -96,7 +96,9 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository {
|
||||
@Override
|
||||
public int reopenIfResolved(String id) {
|
||||
FileRunEventEntity entity = rows.get(id);
|
||||
if (entity == null || entity.getStatus() != FileRunEventStatus.RESOLVED) {
|
||||
if (entity == null
|
||||
|| (entity.getStatus() != FileRunEventStatus.RESOLVED
|
||||
&& entity.getStatus() != FileRunEventStatus.FILE_REMOVED)) {
|
||||
return 0;
|
||||
}
|
||||
entity.setStatus(FileRunEventStatus.NEW);
|
||||
|
||||
+73
@@ -2,6 +2,7 @@ package stirling.software.proprietary.failure;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -120,6 +121,32 @@ class NotificationProjectionTest {
|
||||
.allMatch(action -> action.execution() == FailureActionId.Execution.CLIENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void holdsBackAFailureNamingNoDocumentBecauseTheBellCouldOnlySaySo() {
|
||||
// The only row the bell can offer nothing for. The review surface still lists it.
|
||||
given(FailureKind.UNKNOWN, ACTOR, null);
|
||||
given(FailureKind.INPUT_PASSWORD_PROTECTED, ACTOR, "f-1");
|
||||
|
||||
assertThat(controller.list(null).notifications())
|
||||
.singleElement()
|
||||
.satisfies(row -> assertThat(row.fileId()).isEqualTo("f-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsARunScopedFailureThatStillNamesADocument() {
|
||||
// An editor-reported tool failure is RUN-scoped but names the file it ran on, so
|
||||
// filtering on the kind's scope rather than the row would have dropped it.
|
||||
given(FailureKind.UNKNOWN, ACTOR, "f-2");
|
||||
|
||||
assertThat(controller.list(null).notifications())
|
||||
.singleElement()
|
||||
.satisfies(
|
||||
row -> {
|
||||
assertThat(row.kindId()).isEqualTo("UNKNOWN");
|
||||
assertThat(row.fileId()).isEqualTo("f-2");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void namesTheSourceThatFedAnUnattendedRunSoItsFileIdIsNotMistakenForAClientsOwn() {
|
||||
// Without the source a client looks up a hash it can never resolve and calls it
|
||||
@@ -162,7 +189,53 @@ class NotificationProjectionTest {
|
||||
assertThat(action.labelKey()).startsWith("portal.failures.action.");
|
||||
assertThat(action.defaultLabel()).isNotBlank();
|
||||
assertThat(action.execution()).isNotNull();
|
||||
assertThat(action.slot()).isNotNull();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("the response says whether the caller reviews the team")
|
||||
class ReviewerFlag {
|
||||
|
||||
@Test
|
||||
void trueForAReviewerSoTheClientFiltersNothing() {
|
||||
when(authority.canEditPolicies()).thenReturn(true);
|
||||
|
||||
assertThat(controller.list(null).viewerReviewsTeam()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void falseForAMemberSoTheClientHidesRowsForFilesItDoesNotHold() {
|
||||
when(authority.canEditPolicies()).thenReturn(false);
|
||||
|
||||
assertThat(controller.list(null).viewerReviewsTeam()).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("the response names the viewer, opaquely, for a client to scope read state on")
|
||||
class ViewerKey {
|
||||
|
||||
@Test
|
||||
void steadyForOneViewerAcrossReads() {
|
||||
assertThat(controller.list(null).viewerKey())
|
||||
.isEqualTo(controller.list(null).viewerKey())
|
||||
.isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentForAnotherViewerSoOneCannotInheritTheOthersMarker() {
|
||||
String mine = controller.list(null).viewerKey();
|
||||
when(userService.getCurrentUsername()).thenReturn("someone.else@example.com");
|
||||
|
||||
assertThat(controller.list(null).viewerKey()).isNotEqualTo(mine);
|
||||
}
|
||||
|
||||
@Test
|
||||
void neverTheUsernameItself() {
|
||||
// It lands in that browser's storage, and a client only needs to tell viewers apart.
|
||||
assertThat(controller.list(null).viewerKey()).doesNotContain(ACTOR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package stirling.software.proprietary.failure;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.notification.NotificationController;
|
||||
import stirling.software.proprietary.notification.NotificationService;
|
||||
import stirling.software.proprietary.notification.NotificationView;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
|
||||
/** Reporting a client-side retry that worked: the bell's one write. */
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("reporting a client-side retry that worked")
|
||||
class NotificationResolveTest {
|
||||
|
||||
private static final Long TEAM = 7L;
|
||||
private static final String ACTOR = "reviewer@example.com";
|
||||
|
||||
@Mock private PolicyManagementAuthority authority;
|
||||
@Mock private UserServiceInterface userService;
|
||||
|
||||
private FileRunEventStore store;
|
||||
private FileRunEventService failures;
|
||||
private NotificationController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getSecurity().setEnableLogin(true);
|
||||
store = new FileRunEventStore(new InMemoryFileRunEventRepository());
|
||||
failures =
|
||||
new FileRunEventService(
|
||||
store,
|
||||
new FailureActionRegistry(
|
||||
List.of(new AcknowledgeAction(store), new DismissAction(store))),
|
||||
authority,
|
||||
userService,
|
||||
props);
|
||||
controller = new NotificationController(new NotificationService(failures));
|
||||
|
||||
lenient().when(authority.currentUserTeamId()).thenReturn(TEAM);
|
||||
lenient().when(authority.canEditPolicies()).thenReturn(true);
|
||||
lenient().when(userService.getCurrentUsername()).thenReturn(ACTOR);
|
||||
}
|
||||
|
||||
private FileRunEvent given(FailureKind kind, String actor, String fileId) {
|
||||
return store.record(RecordFailure.forEditor(kind, TEAM, actor, fileId, "boom"));
|
||||
}
|
||||
|
||||
/** The status a refused call came back with. Fails the test if the call was allowed. */
|
||||
private HttpStatus statusOf(Runnable call) {
|
||||
try {
|
||||
call.run();
|
||||
} catch (ResponseStatusException e) {
|
||||
return HttpStatus.valueOf(e.getStatusCode().value());
|
||||
}
|
||||
throw new AssertionError("expected the call to be refused");
|
||||
}
|
||||
|
||||
@Test
|
||||
void closesTheRowBehindThePrefixedId() {
|
||||
// Why the route exists: the bell has no raw id to close its own row with.
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, ACTOR, "f-1");
|
||||
|
||||
NotificationView resolved = controller.resolved("failure:" + event.id());
|
||||
|
||||
assertThat(resolved.status()).isEqualTo(FileRunEventStatus.RESOLVED);
|
||||
assertThat(store.find(event.id(), TEAM).orElseThrow().status())
|
||||
.isEqualTo(FileRunEventStatus.RESOLVED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void theRowsOwnIdIsNotANotificationId() {
|
||||
// Refused outright rather than left to work by accident for whichever source it reaches.
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, ACTOR, "f-1");
|
||||
|
||||
assertThat(statusOf(() -> controller.resolved(event.id())))
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(store.find(event.id(), TEAM).orElseThrow().status())
|
||||
.isEqualTo(FileRunEventStatus.NEW);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnknownSourcePrefixIsABadRequest() {
|
||||
// Not a 404: it was never a notification id, so there is no row to report missing.
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, ACTOR, "f-1");
|
||||
|
||||
assertThat(statusOf(() -> controller.resolved("quota:" + event.id())))
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(statusOf(() -> controller.resolved("failure:")))
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportingTheSameSuccessTwiceIsNotARefusal() {
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, ACTOR, "f-1");
|
||||
NotificationView first = controller.resolved("failure:" + event.id());
|
||||
|
||||
assertThat(controller.resolved("failure:" + event.id()))
|
||||
.isEqualTo(first)
|
||||
.extracting(NotificationView::status)
|
||||
.isEqualTo(FileRunEventStatus.RESOLVED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRowAReviewerHasDismissedIsAConflict() {
|
||||
// Their decision stands: a retry reporting in afterwards does not overwrite it.
|
||||
FileRunEvent event = given(FailureKind.UNKNOWN, ACTOR, "f-1");
|
||||
failures.dispatch(event.id(), "DISMISS", Map.of());
|
||||
|
||||
assertThat(statusOf(() -> controller.resolved("failure:" + event.id())))
|
||||
.isEqualTo(HttpStatus.CONFLICT);
|
||||
assertThat(store.find(event.id(), TEAM).orElseThrow().status())
|
||||
.isEqualTo(FileRunEventStatus.DISMISSED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aColleaguesNotificationIsNotFoundForAMember() {
|
||||
FileRunEvent theirs = given(FailureKind.UNKNOWN, "colleague@example.com", "f-1");
|
||||
when(authority.canEditPolicies()).thenReturn(false);
|
||||
|
||||
assertThat(statusOf(() -> controller.resolved("failure:" + theirs.id())))
|
||||
.isEqualTo(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aReviewerClosesAColleaguesRowTheyFixed() {
|
||||
// Visibility decides, not ownership: a reviewer reads the team's incidents, so a reviewer
|
||||
// who fixes one closes it. The member's own row is unreachable to them the other way round.
|
||||
FileRunEvent theirs = given(FailureKind.UNKNOWN, "colleague@example.com", "f-1");
|
||||
|
||||
controller.resolved("failure:" + theirs.id());
|
||||
|
||||
assertThat(store.find(theirs.id(), TEAM).orElseThrow().status())
|
||||
.isEqualTo(FileRunEventStatus.RESOLVED);
|
||||
}
|
||||
}
|
||||
+39
@@ -15,6 +15,7 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
import stirling.software.proprietary.policy.model.EditorConfig;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.PipelineInput;
|
||||
import stirling.software.proprietary.policy.model.PipelineStep;
|
||||
@@ -223,6 +224,44 @@ class PolicyOverviewServiceTest {
|
||||
teamId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void editorPolicyReportsItsRunMomentRatherThanReadingAsManual() {
|
||||
policyStore.save(
|
||||
new Policy(
|
||||
null,
|
||||
"Editor flatten",
|
||||
"owner",
|
||||
true,
|
||||
List.of(),
|
||||
List.of(new PipelineStep("/api/v1/misc/flatten", Map.of())),
|
||||
OutputSpec.inline(),
|
||||
List.of(),
|
||||
1L,
|
||||
EditorConfig.onUpload()));
|
||||
|
||||
PolicyView view = find(service.overview(), "Editor flatten");
|
||||
|
||||
assertEquals("editor-upload", view.trigger());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sweptPolicyWithNoTriggeredInputIsStillManual() {
|
||||
policyStore.save(
|
||||
new Policy(
|
||||
null,
|
||||
"Swept compress",
|
||||
"owner",
|
||||
true,
|
||||
List.of(),
|
||||
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
|
||||
OutputSpec.inline(),
|
||||
1L));
|
||||
|
||||
PolicyView view = find(service.overview(), "Swept compress");
|
||||
|
||||
assertEquals("manual", view.trigger());
|
||||
}
|
||||
|
||||
private static PolicyView find(PoliciesOverviewResponse response, String name) {
|
||||
return response.pipelines().stream()
|
||||
.filter(view -> view.name().equals(name))
|
||||
|
||||
+18
-2
@@ -64,14 +64,30 @@ class DefaultClassificationPolicySeederTest {
|
||||
assertThat(policy.teamId()).isEqualTo(7L);
|
||||
assertThat(policy.output().type()).isEqualTo("inline");
|
||||
assertThat(policy.output().options().get("categoryId")).isEqualTo("classification");
|
||||
assertThat(policy.output().options().get("runOn")).isEqualTo("upload");
|
||||
assertThat(policy.output().options().get("mode")).isEqualTo("new_version");
|
||||
assertThat(policy.output().options().get("sources")).isEqualTo(List.of("editor"));
|
||||
// Editor participation is the policy's own flag, not a marker in the output options.
|
||||
assertThat(policy.editor().allowed()).isTrue();
|
||||
assertThat(policy.editor().runOn()).isEqualTo("upload");
|
||||
assertThat(policy.steps()).hasSize(1);
|
||||
assertThat(policy.steps().get(0).operation())
|
||||
.isEqualTo("/api/v1/ai/tools/classify-and-label");
|
||||
}
|
||||
|
||||
@Test
|
||||
void marksEditorParticipationOnEditorConfigAndSeedsNoSources() {
|
||||
when(policyStore.findByTeam(7L)).thenReturn(List.of());
|
||||
|
||||
seeder().onTeamCreated(new TeamCreatedEvent(7L, "Acme"));
|
||||
|
||||
ArgumentCaptor<Policy> saved = ArgumentCaptor.forClass(Policy.class);
|
||||
verify(policyStore).save(saved.capture());
|
||||
Policy policy = saved.getValue();
|
||||
// Editor participation is on EditorConfig, not the sources list; the seed carries no
|
||||
// sources.
|
||||
assertThat(policy.editor().allowed()).isTrue();
|
||||
assertThat(policy.output().options().get("sources")).isEqualTo(List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotSeedWhenAClassificationPolicyAlreadyExists() {
|
||||
when(policyStore.findByTeam(7L)).thenReturn(List.of(classificationPolicy(7L)));
|
||||
|
||||
+6
-4
@@ -15,6 +15,7 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
import stirling.software.proprietary.policy.model.EditorConfig;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.PipelineInput;
|
||||
import stirling.software.proprietary.policy.model.PipelineStep;
|
||||
@@ -222,9 +223,7 @@ class SourceOverviewServiceTest {
|
||||
OutputSpec.inline()));
|
||||
}
|
||||
|
||||
/**
|
||||
* A policy that targets the editor: membership rides in its output metadata, not a sourceId.
|
||||
*/
|
||||
/** A policy that targets the editor: membership on its {@link EditorConfig}, not a sourceId. */
|
||||
private void editorPolicy(String name) {
|
||||
policyStore.save(
|
||||
new Policy(
|
||||
@@ -234,7 +233,10 @@ class SourceOverviewServiceTest {
|
||||
true,
|
||||
List.of(),
|
||||
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
|
||||
new OutputSpec("inline", Map.of("sources", List.of("editor")))));
|
||||
OutputSpec.inline(),
|
||||
List.of(),
|
||||
null,
|
||||
EditorConfig.onUpload()));
|
||||
}
|
||||
|
||||
private void teamPolicy(String name, Long teamId, String... sourceIds) {
|
||||
|
||||
+124
@@ -18,6 +18,7 @@ import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.proprietary.policy.model.EditorConfig;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.PipelineInput;
|
||||
import stirling.software.proprietary.policy.model.PipelineStep;
|
||||
@@ -113,6 +114,129 @@ class JpaPolicyStoreTest {
|
||||
upgraded.inputs());
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression this guards: before the editor lift, a blob written by the pre-{@code editor}
|
||||
* seeder deserialized straight onto {@link EditorConfig#disabled()}, silently taking every
|
||||
* upgraded install's Classification policy off the editor.
|
||||
*
|
||||
* <p>The {@code inputs} variant is the important one - {@link
|
||||
* JpaPolicyStore#upgradeLegacyShape} returns early on it, so a lift living inside that method
|
||||
* would miss exactly the rows written between the trigger migration and this field.
|
||||
*/
|
||||
@Test
|
||||
void getLiftsALegacyEditorSourceOntoEditorConfigWhenInputsArePresent() {
|
||||
Policy lifted = readLegacy(legacyJson("\"inputs\":[],", "\"sources\":[\"editor\"],"));
|
||||
|
||||
assertEquals(EditorConfig.onUpload(), lifted.editor());
|
||||
assertEquals(Optional.of("upload"), lifted.editorRunOn());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getLiftsALegacyEditorSourceOnThePreInputsShapeToo() {
|
||||
// Oldest shape: policy-level trigger + sourceIds, so both migrations have to compose.
|
||||
Policy lifted =
|
||||
readLegacy(
|
||||
legacyJson(
|
||||
"\"trigger\":{\"type\":\"schedule\",\"options\":{}},"
|
||||
+ "\"sourceIds\":[\"s1\"],",
|
||||
"\"sources\":[\"editor\"],"));
|
||||
|
||||
assertEquals(EditorConfig.onUpload(), lifted.editor());
|
||||
assertEquals(
|
||||
List.of(new PipelineInput("s1", new TriggerConfig("schedule", Map.of()))),
|
||||
lifted.inputs());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTreatsAnUnnarrowedCataloguePolicyAsEditorRun() {
|
||||
// Empty and absent both meant "nobody narrowed it", which the editor read as its own.
|
||||
assertTrue(readLegacy(legacyJson("\"inputs\":[],", "\"sources\":[],")).editor().allowed());
|
||||
assertTrue(readLegacy(legacyJson("\"inputs\":[],", "")).editor().allowed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getLeavesACataloguePolicyScopedElsewhereOffTheEditor() {
|
||||
Policy lifted = readLegacy(legacyJson("\"inputs\":[],", "\"sources\":[\"sharepoint\"],"));
|
||||
|
||||
assertFalse(lifted.editor().allowed());
|
||||
assertEquals(Optional.empty(), lifted.editorRunOn());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getLeavesASourcelessBuilderPipelineOffTheEditor() {
|
||||
// No categoryId: a pipeline built on the Pipelines page, which never reached the editor.
|
||||
String json =
|
||||
"{\"id\":\"p1\",\"name\":\"legacy\",\"enabled\":true,\"inputs\":[],"
|
||||
+ "\"steps\":[],\"output\":{\"type\":\"inline\",\"options\":{}}}";
|
||||
|
||||
assertFalse(readLegacy(json).editor().allowed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getKeepsTheCategoryDefaultMomentWhenNoRunOnWasStored() {
|
||||
// Security enforced on export before runOn was persisted (frontend runOn.ts
|
||||
// DEFAULT_RUN_ON).
|
||||
String json =
|
||||
"{\"id\":\"p1\",\"name\":\"legacy\",\"enabled\":true,\"inputs\":[],"
|
||||
+ "\"steps\":[],\"output\":{\"type\":\"inline\",\"options\":{"
|
||||
+ "\"categoryId\":\"security\",\"sources\":[\"editor\"]}}}";
|
||||
|
||||
assertEquals(EditorConfig.onExport(), readLegacy(json).editor());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getNeverOverridesAnExplicitlyStoredEditorBlock() {
|
||||
// A deliberate opt-out survives, so the lift stays safe to leave in permanently.
|
||||
String json =
|
||||
"{\"id\":\"p1\",\"name\":\"legacy\",\"enabled\":true,\"inputs\":[],"
|
||||
+ "\"steps\":[],\"editor\":{\"allowed\":false,\"runOn\":\"upload\"},"
|
||||
+ "\"output\":{\"type\":\"inline\",\"options\":{"
|
||||
+ "\"categoryId\":\"classification\",\"sources\":[\"editor\"]}}}";
|
||||
|
||||
assertFalse(readLegacy(json).editor().allowed());
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins the wire shape the stubbed Playwright spec hardcodes: the derived block is additive, so
|
||||
* a real response carries it alongside the untouched legacy options bag.
|
||||
*/
|
||||
@Test
|
||||
void getLeavesTheLegacyOptionsBagIntactSoTheResponseCarriesBoth() {
|
||||
Policy lifted = readLegacy(legacyJson("\"inputs\":[],", "\"sources\":[\"editor\"],"));
|
||||
|
||||
assertEquals(List.of("editor"), lifted.output().options().get("sources"));
|
||||
String wire = objectMapper.writeValueAsString(lifted);
|
||||
assertTrue(
|
||||
wire.contains("\"editor\":{\"allowed\":true,\"runOn\":\"upload\"}"),
|
||||
"expected the derived editor block on the wire, got: " + wire);
|
||||
}
|
||||
|
||||
/**
|
||||
* The blob main's DefaultClassificationPolicySeeder wrote, with the shape bits parameterised.
|
||||
*/
|
||||
private static String legacyJson(String shapeFields, String sourcesField) {
|
||||
return "{\"id\":\"p1\",\"name\":\"Classification Policy\",\"owner\":\"system\","
|
||||
+ "\"enabled\":true,"
|
||||
+ shapeFields
|
||||
+ "\"steps\":[{\"operation\":\"/api/v1/ai/tools/classify-and-label\","
|
||||
+ "\"parameters\":{}}],"
|
||||
+ "\"output\":{\"type\":\"inline\",\"options\":{"
|
||||
+ "\"categoryId\":\"classification\",\"runOn\":\"upload\","
|
||||
+ "\"mode\":\"new_version\","
|
||||
+ sourcesField
|
||||
+ "\"scopeTypes\":[],\"reviewerEmail\":\"\"}},\"teamId\":1}";
|
||||
}
|
||||
|
||||
private Policy readLegacy(String policyJson) {
|
||||
PolicyEntity entity = new PolicyEntity();
|
||||
entity.setId("p1");
|
||||
entity.setName("legacy");
|
||||
entity.setEnabled(true);
|
||||
entity.setPolicyJson(policyJson);
|
||||
when(repository.findById("p1")).thenReturn(Optional.of(entity));
|
||||
return store.get("p1").orElseThrow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveDenormalizesTeamIdForScopedQueries() {
|
||||
store.save(
|
||||
|
||||
+15
-1
@@ -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();
|
||||
|
||||
-2
@@ -324,8 +324,6 @@ class ConnectRequestServiceTest {
|
||||
assertThat(service.claim("nope", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
private static ConnectRequest pending() {
|
||||
ConnectRequest row = new ConnectRequest();
|
||||
row.setRequestId("req");
|
||||
|
||||
+5
-1
@@ -452,8 +452,12 @@ subprojects {
|
||||
if (name == 'stirling-pdf') {
|
||||
apply plugin: 'org.springdoc.openapi-gradle-plugin'
|
||||
|
||||
def openApiPort = rootProject.findProperty('openApiPort')?.toString()
|
||||
?: new ServerSocket(0).withCloseable { it.localPort }.toString()
|
||||
|
||||
openApi {
|
||||
apiDocsUrl = "http://localhost:8080/v1/api-docs"
|
||||
apiDocsUrl = "http://localhost:${openApiPort}/v1/api-docs"
|
||||
customBootRun { args = ["--server.port=${openApiPort}"] }
|
||||
outputDir = file("$projectDir")
|
||||
outputFileName = "SwaggerDoc.json"
|
||||
waitTimeInSeconds = 60 // Increase the wait time to 60 seconds
|
||||
|
||||
@@ -48,9 +48,23 @@ ENV STIRLING_FLAVOR=${STIRLING_FLAVOR}
|
||||
# portal or AI layers change; defaults false so normal builds skip the extra app.
|
||||
ARG BUILD_PORTAL=false
|
||||
|
||||
# Which Stirling account the portal connects to. Build-time because Vite inlines VITE_* into the
|
||||
# bundle; there is no runtime override. Empty leaves the committed .env.proprietary defaults, which
|
||||
# is what an ordinary image wants: no Stirling account and no connect flow. The publishable key is
|
||||
# client-side by design, not a secret. Pass the URL and the key from the same Supabase project or
|
||||
# the browser accepts the pair and Supabase rejects it, which surfaces later as "session expired".
|
||||
ARG VITE_SUPABASE_URL=""
|
||||
ARG VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=""
|
||||
ARG VITE_SAAS_API_URL=""
|
||||
|
||||
# Bundle only the JPDFium native for this image's target arch.
|
||||
ARG TARGETARCH
|
||||
# Exported only when non-empty: Vite reads process.env ahead of the .env files, so exporting an
|
||||
# empty value would blank the committed default rather than fall back to it.
|
||||
RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \
|
||||
if [ -n "${VITE_SUPABASE_URL}" ]; then export VITE_SUPABASE_URL="${VITE_SUPABASE_URL}"; fi; \
|
||||
if [ -n "${VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY}" ]; then export VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY="${VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY}"; fi; \
|
||||
if [ -n "${VITE_SAAS_API_URL}" ]; then export VITE_SAAS_API_URL="${VITE_SAAS_API_URL}"; fi; \
|
||||
STIRLING_FLAVOR=${STIRLING_FLAVOR} \
|
||||
gradle clean build \
|
||||
-PbuildWithFrontend=true \
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
@@ -3641,6 +3641,7 @@ system = "System Configuration"
|
||||
[connect]
|
||||
loading = "Checking this request."
|
||||
redirecting = "Returning you to your server."
|
||||
step = "Step {{current}} of {{total}}"
|
||||
|
||||
[connect.confirm]
|
||||
acknowledge = "I recognise this address and want to connect it to my team"
|
||||
@@ -5505,25 +5506,22 @@ count = "{{remaining}} of {{total}}"
|
||||
label = "Free credits"
|
||||
|
||||
[notifications]
|
||||
empty = "Nothing to report."
|
||||
empty = "You're all caught up."
|
||||
handoffUnavailable = "This browser will not let the processor pass the document to the editor. Open it from the editor instead."
|
||||
noDocumentLinked = "This failure is not linked to a specific document, so there is nothing to open here."
|
||||
notOnThisDevice = "This document is not on this device, so it cannot be opened here."
|
||||
noDocumentLinked = "This failure is not linked to a specific document, so it cannot be opened or retried here."
|
||||
notOnThisDevice = "This document is not on this device, so it cannot be opened or retried here."
|
||||
occurrences = "{{count}} times"
|
||||
open = "Notifications"
|
||||
title = "Notifications"
|
||||
unread = "Unread"
|
||||
|
||||
[notifications.action]
|
||||
copiedLog = "Copied"
|
||||
copyLog = "Copy log"
|
||||
failed = "That did not work. Try again in a moment."
|
||||
more = "More options"
|
||||
unavailable = "Not available for this notification."
|
||||
|
||||
[notifications.detail]
|
||||
copied = "Copied"
|
||||
copy = "Copy error"
|
||||
less = "Show less"
|
||||
more = "Show full message"
|
||||
|
||||
[notifications.section]
|
||||
earlier = "Earlier"
|
||||
new = "New"
|
||||
@@ -5758,10 +5756,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 →"
|
||||
|
||||
@@ -6343,99 +6341,330 @@ REVERSE_ORDER = "Flip the document so the last page becomes first and so on."
|
||||
SIDE_STITCH_BOOKLET_SORT = "Arrange pages for side‑stitch 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."
|
||||
@@ -6452,31 +6681,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"
|
||||
@@ -6572,7 +6782,7 @@ popular = "Popular"
|
||||
selectPlan = "Select Plan"
|
||||
showComparison = "Compare All Features"
|
||||
upgrade = "Upgrade"
|
||||
withServer = "+ Server Plan"
|
||||
withServer = "+ Team plan"
|
||||
|
||||
[plan.api]
|
||||
large = "5,000 Credits"
|
||||
@@ -6590,8 +6800,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"
|
||||
@@ -6616,10 +6826,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]
|
||||
@@ -6648,12 +6858,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"
|
||||
@@ -6676,7 +6886,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"
|
||||
@@ -6697,6 +6907,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."
|
||||
@@ -6804,10 +7018,22 @@ after = "to enable account linking against the hosted Stirling account. In dev y
|
||||
before = "Set"
|
||||
title = "SaaS login not configured"
|
||||
|
||||
[portal.accountLink.connect]
|
||||
close = "Close"
|
||||
notNow = "Not now"
|
||||
start = "Connect Stirling account"
|
||||
step = "Step {{current}} of {{total}}"
|
||||
|
||||
[portal.accountLink.connect.benefits]
|
||||
creditsDetail = "500 free per month"
|
||||
creditsLabel = "Credits"
|
||||
processorDetail = "Pipelines, policies, sources and audit"
|
||||
processorLabel = "Processor"
|
||||
teamsDetail = "Free for up to 5 users"
|
||||
teamsLabel = "Teams"
|
||||
|
||||
[portal.accountLink.connect.callback]
|
||||
continue = "Continue"
|
||||
linkedNotSignedIn = "You are not signed in to Stirling in this browser, so usage and billing will ask you to sign in."
|
||||
modalTitle = "Connecting this server"
|
||||
retry = "Try again"
|
||||
signedInAnyway = "You are signed in to Stirling, so billing and usage will load. Only the server link is incomplete."
|
||||
working = "Finishing the connection."
|
||||
@@ -6816,10 +7042,6 @@ working = "Finishing the connection."
|
||||
body = "Connection requests are short lived. Start another one."
|
||||
title = "Request expired"
|
||||
|
||||
[portal.accountLink.connect.callback.linked]
|
||||
body = "This server is connected to your Stirling account."
|
||||
title = "Server connected"
|
||||
|
||||
[portal.accountLink.connect.callback.malformed]
|
||||
body = "This page was opened without a valid connection response. Start the connection from settings."
|
||||
title = "Could not read the response"
|
||||
@@ -6832,11 +7054,23 @@ title = "Connection not completed"
|
||||
body = "Stirling did not confirm the connection. This is usually temporary."
|
||||
title = "Not finished yet"
|
||||
|
||||
[portal.accountLink.gate]
|
||||
action = "Link account"
|
||||
description = "Link this org's Stirling account to use billable features."
|
||||
title = "Link to unlock"
|
||||
titleFeature = "Link to unlock {{feature}}"
|
||||
[portal.accountLink.connect.done]
|
||||
accountLabel = "Account"
|
||||
addPolicy = "Add a policy"
|
||||
buildPipeline = "Set up a pipeline"
|
||||
creditsBarLabel = "Free credits remaining"
|
||||
creditsSuffix = "of {{allowance}} free credits left"
|
||||
cta = "Done"
|
||||
inviteTeam = "Invite your team"
|
||||
lede = "This server now runs against your Stirling account."
|
||||
pendingTitle = "Almost there"
|
||||
switchOnProcessor = "Switch on the Processor"
|
||||
title = "Connected"
|
||||
|
||||
[portal.accountLink.connect.handoff]
|
||||
going = "Taking you to stirling.com"
|
||||
reauthLede = "Your Stirling session expired. Signing in again keeps usage and billing visible. This server stays connected either way."
|
||||
title = "Connecting"
|
||||
|
||||
[portal.accountLink.instances]
|
||||
active = "Active"
|
||||
@@ -6866,17 +7100,11 @@ never = "never"
|
||||
|
||||
[portal.accountLink.modal]
|
||||
cancel = "Cancel"
|
||||
continueLink = "Continue to Stirling"
|
||||
continueReauth = "Sign in again"
|
||||
linkSubtitle = "Connect this server to the Stirling account it should bill against."
|
||||
linkTitle = "Connect your Stirling account"
|
||||
noAuthorizeUrl = "Stirling did not return somewhere to continue. Try again in a moment."
|
||||
reauthSubtitle = "Your Stirling session expired. Sign in again to keep seeing usage and billing. This server stays connected either way."
|
||||
reauthTitle = "Sign in again"
|
||||
startFailed = "Could not reach Stirling to start the connection. Check this server's outbound network access, then try again."
|
||||
step1 = "We send you to stirling.com to sign in. Any sign-in method works there, including Google and single sign-on."
|
||||
step2 = "You check this server's address and approve it. A team owner has to do this the first time."
|
||||
step3 = "Stirling brings you straight back here and finishes up."
|
||||
|
||||
[portal.accountLink.modal.loginNotConfigured]
|
||||
after = "so this server can finish the connection when you come back."
|
||||
@@ -6895,6 +7123,12 @@ forbidden = "Only the team owner can view the org's linked instances."
|
||||
generic = "Couldn't load the team's linked instances. Try again in a moment."
|
||||
title = "Couldn't load linked instances"
|
||||
|
||||
[portal.accountLink.rail]
|
||||
cta = "Connect"
|
||||
later = "Not now"
|
||||
sub = "Unlocks teams, PDF processor, pipelines, and policies. PDF editing stays free."
|
||||
title = "Connect your Stirling account"
|
||||
|
||||
[portal.accountLink.state]
|
||||
free = "Editor plan"
|
||||
subscribed = "Processor plan"
|
||||
@@ -6982,16 +7216,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]
|
||||
@@ -7018,11 +7252,6 @@ title = "Invoice history"
|
||||
viewAriaLabel = "View invoice {{number}} in Stripe"
|
||||
viewLink = "View ↗"
|
||||
|
||||
[portal.billing.linkPrompt]
|
||||
cta = "Link Stirling account"
|
||||
description = "Manual PDF editing — view, sign, merge, split, watermark, compress, convert, manual OCR — is always free, linked or not. Link to claim 500 free PDFs of metered processing (automation, AI, and the API); when you need more, turn on the Processor plan and only pay for what you use."
|
||||
title = "Link your Stirling account"
|
||||
|
||||
[portal.billing.paymentMethod]
|
||||
billedMonthly = "Billed monthly"
|
||||
cardEnding = "{{brand}} ending {{last4}}"
|
||||
@@ -7178,8 +7407,8 @@ label = "Projected to exceed."
|
||||
|
||||
[portal.billing.spendThisMonth]
|
||||
eyebrow = "Spend this month"
|
||||
freeRemaining_one = "{{formatted}} free PDF remaining"
|
||||
freeRemaining_other = "{{formatted}} free PDFs remaining"
|
||||
freeRemaining_one = "{{formatted}} free credit remaining"
|
||||
freeRemaining_other = "{{formatted}} free credits remaining"
|
||||
processed_one = "{{formattedCount}} PDF processed."
|
||||
processed_other = "{{formattedCount}} PDFs processed."
|
||||
processedWithRate_one = "{{formattedCount}} PDF processed, at {{rate}} each."
|
||||
@@ -7203,10 +7432,10 @@ eyebrow = "Processor trial"
|
||||
statusLabel_one = "{{used}} used"
|
||||
statusLabel_other = "{{used}} used"
|
||||
sub = "Use the PDF Editor for free. Pay to process PDFs automatically."
|
||||
title_one = "Process {{allowance}} PDFs free"
|
||||
title_other = "Process {{allowance}} PDFs free"
|
||||
titleWithRate_one = "Process {{allowance}} PDFs free, then {{rate}}/PDF"
|
||||
titleWithRate_other = "Process {{allowance}} PDFs free, then {{rate}}/PDF"
|
||||
title_one = "{{allowance}} free credit to start"
|
||||
title_other = "{{allowance}} free credits to start"
|
||||
titleWithRate_one = "{{allowance}} free credit, then {{rate}} per PDF"
|
||||
titleWithRate_other = "{{allowance}} free credits, then {{rate}} per PDF"
|
||||
|
||||
[portal.components.billingUnit]
|
||||
approval = "approval"
|
||||
@@ -7880,8 +8109,10 @@ title = "Failures"
|
||||
[portal.failures.action]
|
||||
acknowledge = "Acknowledge"
|
||||
confirm = "Are you sure?"
|
||||
decrypt = "Decrypt and retry"
|
||||
dismiss = "Dismiss"
|
||||
dismissSkipFile = "Skip this file"
|
||||
openInTool = "Retry"
|
||||
viewFile = "View file"
|
||||
viewInProcessor = "View in processor"
|
||||
|
||||
@@ -7904,11 +8135,11 @@ description = "Policy runs that fail will appear here with the actions you can t
|
||||
title = "No failures recorded"
|
||||
|
||||
[portal.failures.kind.inputPasswordProtected]
|
||||
description = "The pipeline could not open the document because it is password-protected. Unlock it and run it again, or skip this file."
|
||||
description = "Your file is password protected, so the run could not read it."
|
||||
title = "Password-protected document"
|
||||
|
||||
[portal.failures.kind.unknown]
|
||||
description = "This run failed for a reason Stirling does not yet recognise. The raw message is shown below."
|
||||
description = "Something went wrong that Stirling does not recognise yet."
|
||||
title = "Unrecognised failure"
|
||||
|
||||
[portal.failures.origin]
|
||||
@@ -8204,6 +8435,9 @@ chooseDestination = "Choose a destination"
|
||||
chooseOperation = "Choose what this step does"
|
||||
chooseSource = "Choose a source"
|
||||
discard = "Discard changes"
|
||||
editorDestination = "Editor"
|
||||
editorDestinationDetail = "Replaces the file you ran it on"
|
||||
editorDestinationHelp = "This pipeline runs on the files in your workspace, and its results replace the file it ran on. There is nowhere else to send them."
|
||||
inputs = "Input"
|
||||
inputSource = "Input source"
|
||||
inputTrigger = "Trigger"
|
||||
@@ -8215,6 +8449,10 @@ needsSource = "No source chosen"
|
||||
noToolMatches = "No tools match your search."
|
||||
pause = "Pause"
|
||||
rename = "Rename pipeline"
|
||||
runOn = "Runs on"
|
||||
runOnExport = "Every export"
|
||||
runOnTooltip = "Choose when this pipeline runs on your files: when you add them, or when you export them."
|
||||
runOnUpload = "Every upload"
|
||||
searchTools = "Search tools"
|
||||
sendToSystem = "Send to another system"
|
||||
stepsIncompatible = "These steps can't run on what their prior step produces: {{tools}}."
|
||||
@@ -8355,6 +8593,8 @@ steps = "Steps"
|
||||
trigger = "Trigger"
|
||||
|
||||
[portal.pipelines.trigger]
|
||||
editor-export = "Every export"
|
||||
editor-upload = "Every upload"
|
||||
folder-watch = "Folder watch"
|
||||
manual = "Manual"
|
||||
schedule = "Scheduled"
|
||||
@@ -10420,18 +10660,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"
|
||||
@@ -11390,9 +11618,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 && (
|
||||
|
||||
@@ -7,6 +7,7 @@ import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFileSize } from "@app/utils/fileUtils";
|
||||
import { toolOperationLabel } from "@app/utils/toolOperationLabel";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||
|
||||
@@ -115,7 +116,7 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{currentFile.toolHistory
|
||||
.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId))
|
||||
.map((tool) => toolOperationLabel(tool, t))
|
||||
.join(" → ")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -10,7 +10,7 @@ import HistoryIcon from "@mui/icons-material/History";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
|
||||
import { FileId, ToolOperation } from "@app/types/file";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import { toolOperationLabel } from "@app/utils/toolOperationLabel";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { formatFileSize, getFileDate } from "@app/utils/fileUtils";
|
||||
import { downloadFileFromStorage } from "@app/utils/downloadUtils";
|
||||
@@ -64,10 +64,10 @@ function deltaToolFor(
|
||||
return curr[priorLen] ?? null;
|
||||
}
|
||||
|
||||
/** Translated tool name via `home.{toolId}.title`. */
|
||||
function ToolLabel({ toolId }: { toolId: ToolId }) {
|
||||
/** The operation's own label when it has one, else its translated tool name. */
|
||||
function ToolLabel({ operation }: { operation: ToolOperation }) {
|
||||
const { t } = useTranslation();
|
||||
return <span>{t(`home.${toolId}.title`, toolId)}</span>;
|
||||
return <span>{toolOperationLabel(operation, t)}</span>;
|
||||
}
|
||||
|
||||
export interface VersionTimelineProps {
|
||||
@@ -242,7 +242,7 @@ export function VersionTimeline({
|
||||
style={{ color: "var(--c-text)" }}
|
||||
>
|
||||
{delta ? (
|
||||
<ToolLabel toolId={delta.toolId} />
|
||||
<ToolLabel operation={delta} />
|
||||
) : (
|
||||
t("filesPage.versionOrigin", "Original upload")
|
||||
)}
|
||||
|
||||
@@ -35,6 +35,25 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Scoped to the bell, so the shared DividerWithText is untouched elsewhere. */
|
||||
.notification-bell__divider.text-divider {
|
||||
margin-top: 0.125rem;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
/* Gray by default, because the shared rule is near-invisible here. */
|
||||
.notification-bell__divider .text-divider__rule {
|
||||
background-color: var(--c-border-strong);
|
||||
}
|
||||
|
||||
.notification-bell__divider--new .text-divider__rule {
|
||||
background-color: var(--c-danger);
|
||||
}
|
||||
|
||||
.notification-bell__divider--new .text-divider__label {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
.notification-bell__panel {
|
||||
position: fixed;
|
||||
z-index: var(--z-popover, 60);
|
||||
@@ -128,38 +147,6 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Expanded, the message is the point of the row, so let it run and scroll rather than clamp. */
|
||||
.notification-bell__detail--full {
|
||||
display: block;
|
||||
max-height: 10rem;
|
||||
overflow-y: auto;
|
||||
-webkit-line-clamp: none;
|
||||
}
|
||||
|
||||
.notification-bell__chrome {
|
||||
grid-column: 2;
|
||||
display: flex;
|
||||
gap: var(--sp-1, 0.25rem);
|
||||
margin-top: var(--sp-1, 0.25rem);
|
||||
}
|
||||
|
||||
/* Reading aids for the message, tinted rather than filled: they sit next to the row's real actions
|
||||
and must not read as one of them. */
|
||||
.notification-bell__chip {
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm, 0.25rem);
|
||||
background: var(--c-primary-subtle);
|
||||
color: var(--c-accent-fg, var(--c-primary));
|
||||
font-size: 0.6875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notification-bell__chip:hover,
|
||||
.notification-bell__chip:focus-visible {
|
||||
background: var(--c-hover);
|
||||
}
|
||||
|
||||
/* Why the actions this row could have had are absent. Muted: it explains, it does not warn. */
|
||||
.notification-bell__note {
|
||||
grid-column: 2;
|
||||
|
||||
@@ -9,21 +9,25 @@ import { MantineProvider } from "@mantine/core";
|
||||
import type {
|
||||
AppNotification,
|
||||
NotificationActionOffer,
|
||||
NotificationActionSlot,
|
||||
} from "@app/services/notifications";
|
||||
|
||||
// @app/ui Button is a Mantine wrapper, so it needs the provider in the tree.
|
||||
const render = (ui: Parameters<typeof baseRender>[0]) =>
|
||||
baseRender(ui, { wrapper: MantineProvider });
|
||||
|
||||
/**
|
||||
* Two things are the bell's own and worth pinning: which notifications the user has already looked
|
||||
* at, and how a row behaves around an action.
|
||||
*/
|
||||
// The bell's own two jobs: what counts as read, and how a row behaves around an action.
|
||||
|
||||
const fetchNotifications = vi.fn();
|
||||
|
||||
// A bare array is wrapped as a reviewer's response; member filtering is the hook's own test.
|
||||
vi.mock("@app/services/notifications", () => ({
|
||||
fetchNotifications: (...args: unknown[]) => fetchNotifications(...args),
|
||||
fetchNotifications: async (...args: unknown[]) => {
|
||||
const value = await fetchNotifications(...args);
|
||||
return Array.isArray(value)
|
||||
? { notifications: value, viewerReviewsTeam: true, viewerKey: "viewer-a" }
|
||||
: value;
|
||||
},
|
||||
}));
|
||||
|
||||
// IndexedDB, which jsdom has none of. Answered here so availability is a fact of the test.
|
||||
@@ -35,7 +39,7 @@ const h = vi.hoisted(() => ({
|
||||
string,
|
||||
{
|
||||
available: (context: unknown) => boolean;
|
||||
run: (context: unknown, password?: string) => unknown;
|
||||
run: (context: unknown) => unknown;
|
||||
closesPanel?: boolean;
|
||||
}
|
||||
>,
|
||||
@@ -58,6 +62,8 @@ vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
// A string fallback, or an options object with defaultValue plus what it interpolates.
|
||||
t: (key: string, fallback?: unknown) => {
|
||||
// The kinds' sentences live in the locale files, so one stands in here.
|
||||
if (key.endsWith(".description")) return "Kind description";
|
||||
if (typeof fallback === "string") return fallback;
|
||||
if (fallback && typeof fallback === "object") {
|
||||
const options = fallback as Record<string, unknown>;
|
||||
@@ -77,18 +83,33 @@ const { NotificationBell } =
|
||||
|
||||
function offer(
|
||||
id: string,
|
||||
slot: NotificationActionSlot = "SECONDARY",
|
||||
overrides: Partial<NotificationActionOffer> = {},
|
||||
): NotificationActionOffer {
|
||||
return {
|
||||
id,
|
||||
labelKey: `portal.failures.action.${id.toLowerCase()}`,
|
||||
defaultLabel: id,
|
||||
slot,
|
||||
enabled: true,
|
||||
disabledReasonKey: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Read state watermarks the ordering time, so rows need distinct ones. "a" is the newest.
|
||||
const AT: Record<string, string> = {
|
||||
a: "2026-08-05T02:00:00Z",
|
||||
b: "2026-08-05T01:00:00Z",
|
||||
};
|
||||
|
||||
/** Scoped to the viewer the mocked response names, as the store writes it. */
|
||||
const READ_THROUGH_KEY = "stirling.notifications.readThroughAt.viewer-a";
|
||||
|
||||
function markReadThrough(iso: string): void {
|
||||
window.localStorage.setItem(READ_THROUGH_KEY, String(Date.parse(iso)));
|
||||
}
|
||||
|
||||
function notification(
|
||||
id: string,
|
||||
title = "Unrecognised failure",
|
||||
@@ -109,8 +130,8 @@ function notification(
|
||||
sourceId: null,
|
||||
policyId: null,
|
||||
occurrences: 1,
|
||||
createdAt: "2026-08-05T00:00:00Z",
|
||||
lastSeenAt: "2026-08-05T00:00:00Z",
|
||||
createdAt: AT[id] ?? "2026-08-05T00:00:00Z",
|
||||
lastSeenAt: AT[id] ?? "2026-08-05T00:00:00Z",
|
||||
actions: [],
|
||||
...overrides,
|
||||
};
|
||||
@@ -172,7 +193,7 @@ describe("NotificationBell", () => {
|
||||
|
||||
it("divides what is new from what the user has already seen", async () => {
|
||||
// "b" was the newest last time, so "a" is the only new one.
|
||||
window.localStorage.setItem("stirling.notifications.lastSeenId", "b");
|
||||
markReadThrough(AT.b);
|
||||
fetchNotifications.mockResolvedValue([
|
||||
notification("a"),
|
||||
notification("b"),
|
||||
@@ -186,7 +207,7 @@ describe("NotificationBell", () => {
|
||||
|
||||
it("keeps the division on screen after opening marks them read", async () => {
|
||||
// Frozen on open: read live it would collapse the moment the badge cleared.
|
||||
window.localStorage.setItem("stirling.notifications.lastSeenId", "b");
|
||||
markReadThrough(AT.b);
|
||||
fetchNotifications.mockResolvedValue([
|
||||
notification("a"),
|
||||
notification("b"),
|
||||
@@ -200,7 +221,7 @@ describe("NotificationBell", () => {
|
||||
});
|
||||
|
||||
it("does not divide a list with nothing new in it", async () => {
|
||||
window.localStorage.setItem("stirling.notifications.lastSeenId", "a");
|
||||
markReadThrough(AT.a);
|
||||
fetchNotifications.mockResolvedValue([notification("a")]);
|
||||
render(<NotificationBell />);
|
||||
await openPanel();
|
||||
@@ -231,29 +252,26 @@ describe("NotificationBell", () => {
|
||||
first.unmount();
|
||||
|
||||
// A newer one arrives above the one already seen.
|
||||
fetchNotifications.mockResolvedValue([
|
||||
notification("b"),
|
||||
notification("a"),
|
||||
]);
|
||||
const arrived = notification("c", "Unrecognised failure", {
|
||||
lastSeenAt: "2026-08-05T03:00:00Z",
|
||||
});
|
||||
fetchNotifications.mockResolvedValue([arrived, notification("a")]);
|
||||
render(<NotificationBell />);
|
||||
|
||||
expect(await screen.findByText("1")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("treats everything as unread when the last seen one is gone", async () => {
|
||||
// We cannot tell how far the user got, so show them rather than marking the lot read.
|
||||
window.localStorage.setItem(
|
||||
"stirling.notifications.lastSeenId",
|
||||
"vanished",
|
||||
);
|
||||
fetchNotifications.mockResolvedValue([
|
||||
notification("a"),
|
||||
notification("b"),
|
||||
]);
|
||||
it("leaves the rest read when the row that was newest has gone", async () => {
|
||||
// The newest row leaves; marking read by id would then relight the badge for the older one.
|
||||
markReadThrough(AT.a);
|
||||
fetchNotifications.mockResolvedValue([notification("b")]);
|
||||
|
||||
render(<NotificationBell />);
|
||||
await openPanel();
|
||||
|
||||
expect(await screen.findByText("2")).toBeTruthy();
|
||||
// Nothing is new, so nothing is labelled new: by id, this row would have counted as unread.
|
||||
expect(await screen.findByText("Unrecognised failure")).toBeTruthy();
|
||||
expect(screen.queryByText("New")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the server's title and repeat count without knowing the source", async () => {
|
||||
@@ -291,6 +309,49 @@ describe("NotificationBell", () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("tucks overflow actions into a menu, not a row of buttons", async () => {
|
||||
h.specs = {
|
||||
DECRYPT: { available: () => true, run: vi.fn() },
|
||||
VIEW_FILE: { available: () => true, run: vi.fn() },
|
||||
VIEW_IN_PROCESSOR: { available: () => true, run: vi.fn() },
|
||||
};
|
||||
fetchNotifications.mockResolvedValue([
|
||||
notification("a", "Unrecognised failure", {
|
||||
actions: [
|
||||
offer("DECRYPT", "RESOLUTION"),
|
||||
offer("VIEW_FILE", "SECONDARY"),
|
||||
offer("VIEW_IN_PROCESSOR", "OVERFLOW"),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
render(<NotificationBell />);
|
||||
await openPanel();
|
||||
|
||||
// Two real buttons; the overflow one is off screen until the menu is opened.
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "DECRYPT: Unrecognised failure",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "VIEW_FILE: Unrecognised failure" }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole("button", {
|
||||
name: "VIEW_IN_PROCESSOR: Unrecognised failure",
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "More options: Unrecognised failure",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("menuitem", { name: "VIEW_IN_PROCESSOR" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("runs whichever of the row's actions is pressed", async () => {
|
||||
const run = vi.fn();
|
||||
h.specs = {
|
||||
@@ -370,7 +431,7 @@ describe("NotificationBell", () => {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(
|
||||
"This document is not on this device, so it cannot be opened here.",
|
||||
"This document is not on this device, so it cannot be opened or retried here.",
|
||||
),
|
||||
).toBeTruthy(),
|
||||
);
|
||||
@@ -387,7 +448,7 @@ describe("NotificationBell", () => {
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"This failure is not linked to a specific document, so there is nothing to open here.",
|
||||
"This failure is not linked to a specific document, so it cannot be opened or retried here.",
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
@@ -422,7 +483,7 @@ describe("NotificationBell", () => {
|
||||
notification("a", "Unrecognised failure", {
|
||||
ownership: "UNOWNED",
|
||||
actions: [
|
||||
offer("VIEW_FILE", {
|
||||
offer("VIEW_FILE", "SECONDARY", {
|
||||
enabled: false,
|
||||
disabledReasonKey: "portal.failures.disabled.unattended",
|
||||
}),
|
||||
@@ -452,11 +513,11 @@ describe("NotificationBell", () => {
|
||||
fetchNotifications.mockResolvedValue([
|
||||
notification("a", "Unrecognised failure", {
|
||||
actions: [
|
||||
offer("VIEW_IN_PROCESSOR", {
|
||||
offer("VIEW_IN_PROCESSOR", "SECONDARY", {
|
||||
enabled: false,
|
||||
disabledReasonKey: "portal.failures.disabled.closed",
|
||||
}),
|
||||
offer("VIEW_FILE", {
|
||||
offer("VIEW_FILE", "SECONDARY", {
|
||||
enabled: false,
|
||||
disabledReasonKey: "portal.failures.disabled.closed",
|
||||
}),
|
||||
@@ -474,7 +535,12 @@ describe("NotificationBell", () => {
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /VIEW_IN_PROCESSOR|VIEW_FILE/ }),
|
||||
).toBeNull();
|
||||
expect(document.querySelector(".notification-bell__actions")).toBeNull();
|
||||
// The error log stays reachable: a row with nothing left to do still owns its detail.
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "More options: Unrecognised failure",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows a failed action in the row instead of leaving the user guessing", async () => {
|
||||
@@ -506,25 +572,43 @@ describe("NotificationBell", () => {
|
||||
expect(screen.getByText("Password-protected document")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("expands the message without touching the row's actions", async () => {
|
||||
it("reads the kind's own words rather than the raw failure", async () => {
|
||||
// A bell is not a log: the row gets a sentence, the message goes in the menu.
|
||||
const stack = "org.apache.pdfbox.InvalidPasswordException";
|
||||
fetchNotifications.mockResolvedValue([
|
||||
notification("a", "Unrecognised failure", {
|
||||
detail: "org.apache.pdfbox.InvalidPasswordException",
|
||||
notification("a", "Password-protected document", {
|
||||
titleKey: "portal.failures.kind.inputPasswordProtected.title",
|
||||
detail: stack,
|
||||
}),
|
||||
]);
|
||||
render(<NotificationBell />);
|
||||
await openPanel();
|
||||
|
||||
const expand = screen.getByRole("button", {
|
||||
name: "Show full message: Unrecognised failure",
|
||||
});
|
||||
fireEvent.click(expand);
|
||||
expect(await screen.findByText("Kind description")).toBeTruthy();
|
||||
expect(screen.queryByText(stack)).toBeNull();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Show less: Unrecognised failure" }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Copy error: Unrecognised failure" }),
|
||||
).toBeTruthy();
|
||||
it("keeps the log one click away, for a row whose only extra is the log", async () => {
|
||||
h.specs = { VIEW_FILE: { available: () => true, run: vi.fn() } };
|
||||
const stack = "org.apache.pdfbox.InvalidPasswordException";
|
||||
const clipboard = vi.fn().mockResolvedValue(undefined);
|
||||
Object.assign(navigator, { clipboard: { writeText: clipboard } });
|
||||
fetchNotifications.mockResolvedValue([
|
||||
notification("a", "Unrecognised failure", {
|
||||
detail: stack,
|
||||
actions: [offer("VIEW_FILE", "SECONDARY")],
|
||||
}),
|
||||
]);
|
||||
render(<NotificationBell />);
|
||||
await openPanel();
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: "More options: Unrecognised failure",
|
||||
}),
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Copy log" }));
|
||||
|
||||
await waitFor(() => expect(clipboard).toHaveBeenCalledWith(stack));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import { useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui";
|
||||
import { Menu, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, Button } from "@app/ui";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { isResolvableHere } from "@app/hooks/useNotifications";
|
||||
import type { NotificationDocumentState } from "@app/hooks/useNotifications";
|
||||
import type {
|
||||
ClientActionRegistry,
|
||||
NotificationActionContext,
|
||||
} from "@app/components/notifications/notificationActions";
|
||||
import { promoteActions } from "@app/components/notifications/notificationActionSlots";
|
||||
import type {
|
||||
AppNotification,
|
||||
NotificationActionOffer,
|
||||
} from "@app/services/notifications";
|
||||
|
||||
/** The kind's own sentence, sharing the portal's copy. */
|
||||
function summaryKeyOf(titleKey: string): string {
|
||||
return titleKey.replace(/\.title$/, ".description");
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's reason wins, being about the failure rather than this browser. Otherwise only what we
|
||||
* actually looked up, so a row we never probed is never called absent.
|
||||
@@ -35,12 +43,12 @@ function noteFor(
|
||||
if (!notification.fileId)
|
||||
return t(
|
||||
"notifications.noDocumentLinked",
|
||||
"This failure is not linked to a specific document, so there is nothing to open here.",
|
||||
"This failure is not linked to a specific document, so it cannot be opened or retried here.",
|
||||
);
|
||||
return isResolvableHere(notification)
|
||||
? t(
|
||||
"notifications.notOnThisDevice",
|
||||
"This document is not on this device, so it cannot be opened here.",
|
||||
"This document is not on this device, so it cannot be opened or retried here.",
|
||||
)
|
||||
: null;
|
||||
}
|
||||
@@ -53,7 +61,7 @@ interface NotificationItemProps {
|
||||
onDismissPanel: () => void;
|
||||
}
|
||||
|
||||
/** Its own component because the last attempt's message and its expanded state are per-row. */
|
||||
/** Its own component because the last attempt's message and the copy state are per-row. */
|
||||
export function NotificationItem({
|
||||
notification,
|
||||
unread,
|
||||
@@ -64,7 +72,6 @@ export function NotificationItem({
|
||||
const { t } = useTranslation();
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const title = t(notification.titleKey, notification.defaultTitle);
|
||||
@@ -73,23 +80,17 @@ export function NotificationItem({
|
||||
hasLocalFile: documentState.hasLocalFile,
|
||||
};
|
||||
|
||||
// An id this build has never heard of is skipped rather than rendered unwired: the server ships
|
||||
// new kinds, and new actions, ahead of the clients that understand them.
|
||||
const usable = notification.actions.filter((offer) => {
|
||||
if (!offer.enabled) return false;
|
||||
const spec = registry[offer.id];
|
||||
return spec ? spec.available(context) : false;
|
||||
});
|
||||
|
||||
// Only from an action this build would otherwise have rendered: a reason about one it cannot
|
||||
// perform anyway is not this row's explanation.
|
||||
const withheldReasonKey =
|
||||
notification.actions.find(
|
||||
(offer) =>
|
||||
!offer.enabled &&
|
||||
offer.disabledReasonKey !== null &&
|
||||
registry[offer.id] !== undefined,
|
||||
)?.disabledReasonKey ?? null;
|
||||
const { primary, secondary, overflow, withheldReasonKey } = promoteActions(
|
||||
notification.actions,
|
||||
(offer) => {
|
||||
const spec = registry[offer.id];
|
||||
// An id this build has never heard of: skipped rather than rendered unwired.
|
||||
if (!spec) return false;
|
||||
return spec.available(context);
|
||||
},
|
||||
// A reason from an action this build could not have rendered explains nothing.
|
||||
(offer) => registry[offer.id] !== undefined,
|
||||
);
|
||||
|
||||
const labelOf = (offer: NotificationActionOffer) =>
|
||||
t(offer.labelKey, offer.defaultLabel);
|
||||
@@ -129,6 +130,7 @@ export function NotificationItem({
|
||||
};
|
||||
|
||||
const note = noteFor(notification, documentState, withheldReasonKey, t);
|
||||
const summary = t(summaryKeyOf(notification.titleKey), { defaultValue: "" });
|
||||
|
||||
return (
|
||||
<li
|
||||
@@ -151,62 +153,71 @@ export function NotificationItem({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{notification.detail && (
|
||||
<>
|
||||
<span
|
||||
className={
|
||||
expanded
|
||||
? "notification-bell__detail notification-bell__detail--full"
|
||||
: "notification-bell__detail"
|
||||
}
|
||||
>
|
||||
{notification.detail}
|
||||
</span>
|
||||
<span className="notification-bell__chrome">
|
||||
<button
|
||||
type="button"
|
||||
className="notification-bell__chip"
|
||||
aria-label={`${t("notifications.detail.copy", "Copy error")}: ${title}`}
|
||||
onClick={() => void copyDetail()}
|
||||
>
|
||||
{copied
|
||||
? t("notifications.detail.copied", "Copied")
|
||||
: t("notifications.detail.copy", "Copy error")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="notification-bell__chip"
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${
|
||||
expanded
|
||||
? t("notifications.detail.less", "Show less")
|
||||
: t("notifications.detail.more", "Show full message")
|
||||
}: ${title}`}
|
||||
onClick={() => setExpanded((wasExpanded) => !wasExpanded)}
|
||||
>
|
||||
{expanded
|
||||
? t("notifications.detail.less", "Show less")
|
||||
: t("notifications.detail.more", "Show full message")}
|
||||
</button>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{summary && <span className="notification-bell__detail">{summary}</span>}
|
||||
|
||||
{note && <span className="notification-bell__note">{note}</span>}
|
||||
|
||||
{/* In the kind's declared order, the first leading. */}
|
||||
{usable.length > 0 && (
|
||||
{/* The menu is not gated on a button existing: a row with no action still owns its log. */}
|
||||
{(primary || notification.detail) && (
|
||||
<span className="notification-bell__actions">
|
||||
{usable.map((offer, index) => (
|
||||
{primary && (
|
||||
<ActionButton
|
||||
key={offer.id}
|
||||
variant={index === 0 ? "primary" : "secondary"}
|
||||
variant="primary"
|
||||
rowTitle={title}
|
||||
label={labelOf(offer)}
|
||||
busy={busy === offer.id}
|
||||
onRun={() => void run(offer)}
|
||||
label={labelOf(primary)}
|
||||
busy={busy === primary.id}
|
||||
onRun={() => void run(primary)}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
{secondary && (
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
rowTitle={title}
|
||||
label={labelOf(secondary)}
|
||||
busy={busy === secondary.id}
|
||||
onRun={() => void run(secondary)}
|
||||
/>
|
||||
)}
|
||||
{(overflow.length > 0 || notification.detail) && (
|
||||
<Menu withinPortal position="bottom-end" shadow="md" width={180}>
|
||||
<Menu.Target>
|
||||
<Tooltip
|
||||
label={t("notifications.action.more", "More options")}
|
||||
withinPortal
|
||||
>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
className="notification-bell__more"
|
||||
aria-label={`${t("notifications.action.more", "More options")}: ${title}`}
|
||||
>
|
||||
<LocalIcon icon="more-horiz" width={14} height={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown className="notification-bell__menu">
|
||||
{overflow.map((offer) => (
|
||||
<Menu.Item
|
||||
key={offer.id}
|
||||
disabled={busy === offer.id}
|
||||
onClick={() => void run(offer)}
|
||||
>
|
||||
{labelOf(offer)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
{notification.detail && (
|
||||
<Menu.Item
|
||||
closeMenuOnClick={false}
|
||||
onClick={() => void copyDetail()}
|
||||
>
|
||||
{copied
|
||||
? t("notifications.action.copiedLog", "Copied")
|
||||
: t("notifications.action.copyLog", "Copy log")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -220,7 +231,8 @@ export function NotificationItem({
|
||||
}
|
||||
|
||||
interface ActionButtonProps {
|
||||
variant: "primary" | "secondary";
|
||||
/** Solid for the row's answer, outlined for its runner-up, ghost for the rest. */
|
||||
variant: "primary" | "secondary" | "tertiary";
|
||||
rowTitle: string;
|
||||
label: string;
|
||||
busy: boolean;
|
||||
|
||||
@@ -65,6 +65,8 @@ export function NotificationPanel({
|
||||
if (panel.current?.contains(target)) return;
|
||||
// A trigger closes this itself; counting it as outside would reopen it.
|
||||
if (target.closest?.("[data-notifications-trigger]")) return;
|
||||
// The overflow menu is portaled out, so a click in it would read as outside the panel.
|
||||
if (target.closest?.(".notification-bell__menu")) return;
|
||||
onClose();
|
||||
};
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
@@ -98,7 +100,7 @@ export function NotificationPanel({
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<p className="notification-bell__empty">
|
||||
{t("notifications.empty", "Nothing to report.")}
|
||||
{t("notifications.empty", "You're all caught up.")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="notification-bell__list">
|
||||
@@ -107,6 +109,7 @@ export function NotificationPanel({
|
||||
{index === 0 && dividedAt > 0 && (
|
||||
<li aria-hidden>
|
||||
<DividerWithText
|
||||
className="notification-bell__divider notification-bell__divider--new"
|
||||
text={t("notifications.section.new", "New")}
|
||||
/>
|
||||
</li>
|
||||
@@ -115,6 +118,7 @@ export function NotificationPanel({
|
||||
{index === dividedAt && dividedAt > 0 && (
|
||||
<li aria-hidden>
|
||||
<DividerWithText
|
||||
className="notification-bell__divider"
|
||||
text={t("notifications.section.earlier", "Earlier")}
|
||||
/>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { promoteActions } from "@app/components/notifications/notificationActionSlots";
|
||||
import type {
|
||||
NotificationActionOffer,
|
||||
NotificationActionSlot,
|
||||
} from "@app/services/notifications";
|
||||
|
||||
// Pinned against the shapes the server sends: what is left over depends on what won the buttons.
|
||||
|
||||
/** The offers as `FailureKind` declares them for an unrecognised failure. */
|
||||
const UNKNOWN_OFFERS: Record<string, NotificationActionOffer> = {
|
||||
OPEN_IN_TOOL: offer("OPEN_IN_TOOL", "SECONDARY"),
|
||||
VIEW_IN_PROCESSOR: offer("VIEW_IN_PROCESSOR", "SECONDARY"),
|
||||
VIEW_FILE: offer("VIEW_FILE", "OVERFLOW"),
|
||||
};
|
||||
|
||||
const PASSWORD_OFFERS: Record<string, NotificationActionOffer> = {
|
||||
DECRYPT: offer("DECRYPT", "RESOLUTION"),
|
||||
OPEN_IN_TOOL: offer("OPEN_IN_TOOL", "OVERFLOW"),
|
||||
VIEW_FILE: offer("VIEW_FILE", "OVERFLOW"),
|
||||
VIEW_IN_PROCESSOR: offer("VIEW_IN_PROCESSOR", "SECONDARY"),
|
||||
};
|
||||
|
||||
/** The reasons the server sends with an action it would refuse. */
|
||||
const NO_DOCUMENT = "portal.failures.disabled.noDocument";
|
||||
const UNATTENDED = "portal.failures.disabled.unattended";
|
||||
const CLOSED = "portal.failures.disabled.closed";
|
||||
|
||||
function offer(
|
||||
id: string,
|
||||
slot: NotificationActionSlot,
|
||||
overrides: Partial<NotificationActionOffer> = {},
|
||||
): NotificationActionOffer {
|
||||
return {
|
||||
id,
|
||||
labelKey: `portal.failures.action.${id.toLowerCase()}`,
|
||||
defaultLabel: id,
|
||||
slot,
|
||||
enabled: true,
|
||||
disabledReasonKey: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function from(
|
||||
declared: Record<string, NotificationActionOffer>,
|
||||
ids: string[],
|
||||
): NotificationActionOffer[] {
|
||||
return ids.map((id) => {
|
||||
const found = declared[id];
|
||||
if (!found) throw new Error(`That kind offers no ${id}`);
|
||||
return found;
|
||||
});
|
||||
}
|
||||
|
||||
const unknown = (...ids: string[]) => from(UNKNOWN_OFFERS, ids);
|
||||
|
||||
const password = (...ids: string[]) => from(PASSWORD_OFFERS, ids);
|
||||
|
||||
/** The same offers, with the named ones refused as the server would refuse them. */
|
||||
function refusing(
|
||||
offers: NotificationActionOffer[],
|
||||
reasonKey: string,
|
||||
...ids: string[]
|
||||
): NotificationActionOffer[] {
|
||||
return offers.map((action) =>
|
||||
ids.includes(action.id)
|
||||
? { ...action, enabled: false, disabledReasonKey: reasonKey }
|
||||
: action,
|
||||
);
|
||||
}
|
||||
|
||||
/** Everything this client can do, with the file on this device. */
|
||||
const RUNNABLE = new Set([
|
||||
"OPEN_IN_TOOL",
|
||||
"DECRYPT",
|
||||
"VIEW_FILE",
|
||||
"VIEW_IN_PROCESSOR",
|
||||
]);
|
||||
|
||||
/** The predicate the bell supplies: a known id, on a device that can act on it. */
|
||||
const canRun = (action: NotificationActionOffer) => RUNNABLE.has(action.id);
|
||||
|
||||
/** The build's knowledge alone, which is what gates a withheld reason. */
|
||||
const knowsAction = (action: NotificationActionOffer) =>
|
||||
RUNNABLE.has(action.id);
|
||||
|
||||
function promoted(list: NotificationActionOffer[]) {
|
||||
const { primary, secondary, overflow, withheldReasonKey } = promoteActions(
|
||||
list,
|
||||
canRun,
|
||||
knowsAction,
|
||||
);
|
||||
return {
|
||||
primary: primary?.id ?? null,
|
||||
secondary: secondary?.id ?? null,
|
||||
overflow: overflow.map((action) => action.id),
|
||||
withheldReasonKey,
|
||||
};
|
||||
}
|
||||
|
||||
describe("promoteActions", () => {
|
||||
it("gives the owner the retry, and keeps the rest quiet behind it", () => {
|
||||
// No portal access, so the server never offered the processor link.
|
||||
expect(promoted(unknown("OPEN_IN_TOOL", "VIEW_FILE"))).toEqual({
|
||||
primary: "OPEN_IN_TOOL",
|
||||
secondary: null,
|
||||
overflow: ["VIEW_FILE"],
|
||||
withheldReasonKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("leads an attended policy failure with the queue, and states what was refused", () => {
|
||||
// Not the reader's document, so a greyed unlock would be false hope: the note stays instead.
|
||||
expect(
|
||||
promoted(
|
||||
refusing(
|
||||
unknown("OPEN_IN_TOOL", "VIEW_IN_PROCESSOR", "VIEW_FILE"),
|
||||
NO_DOCUMENT,
|
||||
"OPEN_IN_TOOL",
|
||||
"VIEW_FILE",
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
primary: "VIEW_IN_PROCESSOR",
|
||||
secondary: null,
|
||||
overflow: [],
|
||||
withheldReasonKey: NO_DOCUMENT,
|
||||
});
|
||||
});
|
||||
|
||||
it("leads an unattended failure with the queue, and says retrying is not available", () => {
|
||||
// Nobody holds the document: one reason for the row, from the best thing it lost.
|
||||
expect(
|
||||
promoted(
|
||||
refusing(
|
||||
unknown("OPEN_IN_TOOL", "VIEW_IN_PROCESSOR", "VIEW_FILE"),
|
||||
UNATTENDED,
|
||||
"OPEN_IN_TOOL",
|
||||
"VIEW_FILE",
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
primary: "VIEW_IN_PROCESSOR",
|
||||
secondary: null,
|
||||
overflow: [],
|
||||
withheldReasonKey: UNATTENDED,
|
||||
});
|
||||
});
|
||||
|
||||
it("explains nothing on a colleague's failure, having taken nothing away", () => {
|
||||
// Nothing needing the bytes was offered, so there is no loss to account for.
|
||||
expect(promoted(unknown("VIEW_IN_PROCESSOR"))).toEqual({
|
||||
primary: "VIEW_IN_PROCESSOR",
|
||||
secondary: null,
|
||||
overflow: [],
|
||||
withheldReasonKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("leads a password failure with the unlock, not the plain retry", () => {
|
||||
// Running it again unchanged is a second answer to the same problem, so it drops behind.
|
||||
expect(promoted(password("DECRYPT", "OPEN_IN_TOOL", "VIEW_FILE"))).toEqual({
|
||||
primary: "DECRYPT",
|
||||
secondary: null,
|
||||
overflow: ["OPEN_IN_TOOL", "VIEW_FILE"],
|
||||
withheldReasonKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("gives a reviewer their own password failure the unlock plus the queue", () => {
|
||||
expect(
|
||||
promoted(
|
||||
password("DECRYPT", "OPEN_IN_TOOL", "VIEW_FILE", "VIEW_IN_PROCESSOR"),
|
||||
),
|
||||
).toEqual({
|
||||
primary: "DECRYPT",
|
||||
secondary: "VIEW_IN_PROCESSOR",
|
||||
overflow: ["OPEN_IN_TOOL", "VIEW_FILE"],
|
||||
withheldReasonKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves a closed row no buttons at all, only its reason", () => {
|
||||
// Already closed elsewhere: every offer refused, so the row is its message plus one line.
|
||||
expect(
|
||||
promoted(
|
||||
refusing(
|
||||
unknown("OPEN_IN_TOOL", "VIEW_FILE"),
|
||||
CLOSED,
|
||||
"OPEN_IN_TOOL",
|
||||
"VIEW_FILE",
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
primary: null,
|
||||
secondary: null,
|
||||
overflow: [],
|
||||
withheldReasonKey: CLOSED,
|
||||
});
|
||||
});
|
||||
|
||||
it("promotes past a resolution the shell cannot deliver", () => {
|
||||
// Read from the processor, which has no FileContext, so the unlock reports itself unavailable.
|
||||
const inProcessor = (action: NotificationActionOffer) =>
|
||||
action.id !== "DECRYPT" && canRun(action);
|
||||
|
||||
const { primary, secondary, overflow } = promoteActions(
|
||||
password("DECRYPT", "OPEN_IN_TOOL", "VIEW_FILE", "VIEW_IN_PROCESSOR"),
|
||||
inProcessor,
|
||||
knowsAction,
|
||||
);
|
||||
|
||||
expect(primary?.id).toBe("VIEW_IN_PROCESSOR");
|
||||
expect(secondary).toBeNull();
|
||||
expect(overflow.map((action) => action.id)).toEqual([
|
||||
"OPEN_IN_TOOL",
|
||||
"VIEW_FILE",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops a client action this device cannot perform, without inventing a reason", () => {
|
||||
// The document is gone from this browser: the actions disappear rather than fail on click.
|
||||
const { primary, overflow, withheldReasonKey } = promoteActions(
|
||||
unknown("OPEN_IN_TOOL", "VIEW_FILE"),
|
||||
() => false,
|
||||
knowsAction,
|
||||
);
|
||||
|
||||
expect(primary).toBeNull();
|
||||
expect(overflow).toEqual([]);
|
||||
expect(withheldReasonKey).toBeNull();
|
||||
});
|
||||
|
||||
it("skips an action id it has never heard of without touching the rest", () => {
|
||||
// The server ships a kind with a new action before this build knows what it means.
|
||||
const list = [
|
||||
offer("QUARANTINE", "RESOLUTION"),
|
||||
...unknown("OPEN_IN_TOOL"),
|
||||
];
|
||||
|
||||
expect(promoted(list)).toEqual({
|
||||
primary: "OPEN_IN_TOOL",
|
||||
secondary: null,
|
||||
overflow: [],
|
||||
withheldReasonKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("has nothing to promote when nothing survives", () => {
|
||||
expect(
|
||||
promoteActions(
|
||||
[],
|
||||
() => true,
|
||||
() => true,
|
||||
),
|
||||
).toEqual({
|
||||
primary: null,
|
||||
secondary: null,
|
||||
overflow: [],
|
||||
withheldReasonKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("never explains the row with an action this build has never heard of", () => {
|
||||
// A client that could never have drawn the button is not explained by its reason.
|
||||
const list = [
|
||||
offer("QUARANTINE", "RESOLUTION", {
|
||||
enabled: false,
|
||||
disabledReasonKey: NO_DOCUMENT,
|
||||
}),
|
||||
...unknown("VIEW_IN_PROCESSOR"),
|
||||
];
|
||||
|
||||
expect(promoted(list)).toEqual({
|
||||
primary: "VIEW_IN_PROCESSOR",
|
||||
secondary: null,
|
||||
overflow: [],
|
||||
withheldReasonKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("takes the reason from the best action lost, not the first declared", () => {
|
||||
// Two refusals, one row: the reader gets the one they would have reached for first.
|
||||
const list = [
|
||||
offer("VIEW_FILE", "OVERFLOW", {
|
||||
enabled: false,
|
||||
disabledReasonKey: CLOSED,
|
||||
}),
|
||||
offer("DECRYPT", "RESOLUTION", {
|
||||
enabled: false,
|
||||
disabledReasonKey: NO_DOCUMENT,
|
||||
}),
|
||||
...password("VIEW_IN_PROCESSOR"),
|
||||
];
|
||||
|
||||
expect(promoted(list).withheldReasonKey).toBe(NO_DOCUMENT);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type {
|
||||
NotificationActionOffer,
|
||||
NotificationActionSlot,
|
||||
} from "@app/services/notifications";
|
||||
|
||||
// The server says what an action does and what it has earned; this turns that into an order.
|
||||
|
||||
const SLOT_RANK: Record<NotificationActionSlot, number> = {
|
||||
RESOLUTION: 0,
|
||||
SECONDARY: 1,
|
||||
OVERFLOW: 2,
|
||||
};
|
||||
|
||||
export interface PromotedActions {
|
||||
/** The row's own button. Null when nothing survived the filter. */
|
||||
primary: NotificationActionOffer | null;
|
||||
/** A second button, only ever an action the server marked SECONDARY. */
|
||||
secondary: NotificationActionOffer | null;
|
||||
/** Everything else, in the server's order, for the row to render quietly after those two. */
|
||||
overflow: NotificationActionOffer[];
|
||||
/** The reason for the best action withheld, for the row to state once. */
|
||||
withheldReasonKey: string | null;
|
||||
}
|
||||
|
||||
/** One primary, at most one secondary, and the quiet rest. A disabled action is dropped. */
|
||||
export function promoteActions(
|
||||
offers: readonly NotificationActionOffer[],
|
||||
canRenderClientAction: (offer: NotificationActionOffer) => boolean,
|
||||
knowsAction: (offer: NotificationActionOffer) => boolean,
|
||||
): PromotedActions {
|
||||
const ranked = offers
|
||||
.map((offer, declaredAt) => ({ offer, declaredAt }))
|
||||
// Slot first, then declaration order, so two actions in one slot keep the server's ranking.
|
||||
.sort(
|
||||
(a, b) =>
|
||||
SLOT_RANK[a.offer.slot] - SLOT_RANK[b.offer.slot] ||
|
||||
a.declaredAt - b.declaredAt,
|
||||
)
|
||||
.map(({ offer }) => offer);
|
||||
|
||||
// The best one withheld, so a row explains itself once rather than once per lost action.
|
||||
const withheldReasonKey =
|
||||
ranked.find(
|
||||
(offer) =>
|
||||
!offer.enabled && offer.disabledReasonKey && knowsAction(offer),
|
||||
)?.disabledReasonKey ?? null;
|
||||
|
||||
const renderable = ranked.filter(
|
||||
(offer) => offer.enabled && canRenderClientAction(offer),
|
||||
);
|
||||
|
||||
const [primary, next, ...rest] = renderable;
|
||||
if (!primary)
|
||||
return { primary: null, secondary: null, overflow: [], withheldReasonKey };
|
||||
|
||||
// A second RESOLUTION would read as two answers to one problem; OVERFLOW was ranked below.
|
||||
const secondary = next?.slot === "SECONDARY" ? next : null;
|
||||
|
||||
return {
|
||||
primary,
|
||||
secondary,
|
||||
overflow: secondary ? rest : next ? [next, ...rest] : rest,
|
||||
withheldReasonKey,
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
import React from "react";
|
||||
import { Text, Tooltip, Badge, Group } from "@mantine/core";
|
||||
import { ToolOperation } from "@app/types/file";
|
||||
import { toolOperationLabel } from "@app/utils/toolOperationLabel";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
|
||||
interface ToolChainProps {
|
||||
toolChain: ToolOperation[];
|
||||
@@ -29,11 +29,7 @@ const ToolChain: React.FC<ToolChainProps> = ({
|
||||
const { t } = useTranslation();
|
||||
if (!toolChain || toolChain.length === 0) return null;
|
||||
|
||||
const toolIds = toolChain.map((tool) => tool.toolId);
|
||||
|
||||
const getToolName = (toolId: ToolId) => {
|
||||
return t(`home.${toolId}.title`, toolId);
|
||||
};
|
||||
const getToolName = (tool: ToolOperation) => toolOperationLabel(tool, t);
|
||||
|
||||
// Create full tool chain for tooltip
|
||||
const fullChainDisplay =
|
||||
@@ -42,7 +38,7 @@ const ToolChain: React.FC<ToolChainProps> = ({
|
||||
{toolChain.map((tool, index) => (
|
||||
<React.Fragment key={`${tool.toolId}-${index}`}>
|
||||
<Badge size="sm" variant="light" color="blue">
|
||||
{getToolName(tool.toolId)}
|
||||
{getToolName(tool)}
|
||||
</Badge>
|
||||
{index < toolChain.length - 1 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
@@ -53,18 +49,21 @@ const ToolChain: React.FC<ToolChainProps> = ({
|
||||
))}
|
||||
</Group>
|
||||
) : (
|
||||
<Text size="sm">{toolIds.map(getToolName).join(" → ")}</Text>
|
||||
<Text size="sm">{toolChain.map(getToolName).join(" → ")}</Text>
|
||||
);
|
||||
|
||||
// Create truncated display based on available space
|
||||
const getTruncatedDisplay = () => {
|
||||
if (toolIds.length <= 2) {
|
||||
if (toolChain.length <= 2) {
|
||||
// Show all tools if 2 or fewer
|
||||
return { text: toolIds.map(getToolName).join(" → "), isTruncated: false };
|
||||
return {
|
||||
text: toolChain.map(getToolName).join(" → "),
|
||||
isTruncated: false,
|
||||
};
|
||||
} else {
|
||||
// Show first tool ... last tool for longer chains
|
||||
return {
|
||||
text: `${getToolName(toolIds[0])} → +${toolIds.length - 2} → ${getToolName(toolIds[toolIds.length - 1])}`,
|
||||
text: `${getToolName(toolChain[0])} → +${toolChain.length - 2} → ${getToolName(toolChain[toolChain.length - 1])}`,
|
||||
isTruncated: true,
|
||||
};
|
||||
}
|
||||
@@ -75,10 +74,10 @@ const ToolChain: React.FC<ToolChainProps> = ({
|
||||
// Compact style for very small spaces
|
||||
if (displayStyle === "compact") {
|
||||
const compactText =
|
||||
toolIds.length === 1
|
||||
? getToolName(toolIds[0])
|
||||
: `${toolIds.length} tools`;
|
||||
const isCompactTruncated = toolIds.length > 1;
|
||||
toolChain.length === 1
|
||||
? getToolName(toolChain[0])
|
||||
: `${toolChain.length} tools`;
|
||||
const isCompactTruncated = toolChain.length > 1;
|
||||
|
||||
const compactElement = (
|
||||
<Text
|
||||
@@ -116,7 +115,7 @@ const ToolChain: React.FC<ToolChainProps> = ({
|
||||
{toolChain.slice(0, 3).map((tool, index) => (
|
||||
<React.Fragment key={`${tool.toolId}-${index}`}>
|
||||
<Badge size={size} variant="light" color="blue">
|
||||
{getToolName(tool.toolId)}
|
||||
{getToolName(tool)}
|
||||
</Badge>
|
||||
{index < Math.min(toolChain.length - 1, 2) && (
|
||||
<Text size="xs" c="dimmed">
|
||||
@@ -131,7 +130,7 @@ const ToolChain: React.FC<ToolChainProps> = ({
|
||||
...
|
||||
</Text>
|
||||
<Badge size={size} variant="light" color="blue">
|
||||
{getToolName(toolChain[toolChain.length - 1].toolId)}
|
||||
{getToolName(toolChain[toolChain.length - 1])}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
@@ -140,7 +139,7 @@ const ToolChain: React.FC<ToolChainProps> = ({
|
||||
);
|
||||
|
||||
return isBadgesTruncated ? (
|
||||
<Tooltip label={`${toolIds.map(getToolName).join(" → ")}`} withinPortal>
|
||||
<Tooltip label={`${toolChain.map(getToolName).join(" → ")}`} withinPortal>
|
||||
{badgesElement}
|
||||
</Tooltip>
|
||||
) : (
|
||||
|
||||
@@ -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" />,
|
||||
},
|
||||
|
||||
+11
-5
@@ -35,7 +35,11 @@ function notification(id: string): AppNotification {
|
||||
describe("QuickNavRailNotifications", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
fetchNotifications.mockReset().mockResolvedValue([]);
|
||||
fetchNotifications.mockReset().mockResolvedValue({
|
||||
notifications: [],
|
||||
viewerReviewsTeam: true,
|
||||
viewerKey: "viewer-a",
|
||||
});
|
||||
h.notificationsAvailable = true;
|
||||
});
|
||||
|
||||
@@ -53,10 +57,12 @@ describe("QuickNavRailNotifications", () => {
|
||||
});
|
||||
|
||||
it("carries the unread count on the icon", async () => {
|
||||
fetchNotifications.mockResolvedValue([
|
||||
notification("a"),
|
||||
notification("b"),
|
||||
]);
|
||||
// A reviewer's response, so nothing is filtered for want of a local document.
|
||||
fetchNotifications.mockResolvedValue({
|
||||
notifications: [notification("a"), notification("b")],
|
||||
viewerReviewsTeam: true,
|
||||
viewerKey: "viewer-a",
|
||||
});
|
||||
|
||||
render(<QuickNavRailNotifications onToggle={() => {}} />);
|
||||
|
||||
|
||||
@@ -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
@@ -192,6 +192,39 @@ describe("fileContextReducer — silent CONSUME_FILES (background enforcement)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("carries a no-label [] verdict forward (classified, not unclassified)", () => {
|
||||
// "a" was classified and found nothing ([]) - distinct from null (never classified). The output
|
||||
// must inherit [] so the local pass treats it as already-classified and never re-classifies (or
|
||||
// re-bills) it.
|
||||
const start = stateWith([stub("a", { classificationLabels: [] })]);
|
||||
const next = fileContextReducer(start, {
|
||||
type: "CONSUME_FILES",
|
||||
payload: {
|
||||
inputFileIds: ["a" as FileId],
|
||||
outputStirlingFileStubs: [stub("b")],
|
||||
},
|
||||
});
|
||||
expect(next.files.byId["b" as FileId].classificationLabels).toEqual([]);
|
||||
});
|
||||
|
||||
it("prefers a real label over a merge input's no-label [] verdict", () => {
|
||||
// Merge of a labelled file and a no-label one: the output should keep the real label.
|
||||
const start = stateWith([
|
||||
stub("a", { classificationLabels: [] }),
|
||||
stub("b", { classificationLabels: ["Invoice"] }),
|
||||
]);
|
||||
const next = fileContextReducer(start, {
|
||||
type: "CONSUME_FILES",
|
||||
payload: {
|
||||
inputFileIds: ["a" as FileId, "b" as FileId],
|
||||
outputStirlingFileStubs: [stub("c")],
|
||||
},
|
||||
});
|
||||
expect(next.files.byId["c" as FileId].classificationLabels).toEqual([
|
||||
"Invoice",
|
||||
]);
|
||||
});
|
||||
|
||||
it("an output's own classificationLabels win over the input's", () => {
|
||||
// A re-classify produces an output that already carries (fresher) labels.
|
||||
const start = stateWith([stub("a", { classificationLabels: ["Invoice"] })]);
|
||||
@@ -211,7 +244,7 @@ describe("fileContextReducer — silent CONSUME_FILES (background enforcement)",
|
||||
|
||||
it("carries classificationConfidence forward with the labels", () => {
|
||||
// The confidence is part of the verdict: without it the escalation decision
|
||||
// (shouldDispatchToAi) dies at the version boundary and a chained
|
||||
// (localVerdictNeedsEscalation) dies at the version boundary and a chained
|
||||
// classification never runs.
|
||||
const start = stateWith([
|
||||
stub("a", {
|
||||
|
||||
@@ -389,16 +389,16 @@ export function fileContextReducer(
|
||||
// Carry the document's classification verdict forward across the edit: any
|
||||
// tool that versions/derives a classified file keeps it in its label
|
||||
// groups instead of dropping to "Other" and waiting on a PDF re-read.
|
||||
// Inherited from the first input that has labels, together with that
|
||||
// verdict's confidence - the escalation decision (shouldDispatchToAi) is
|
||||
// about the document, not about which step produced the current bytes, so
|
||||
// it must survive the version boundary. An output that already carries its
|
||||
// own verdict (e.g. a fresh classify result) keeps it.
|
||||
const verdictDonor = inputFileIds
|
||||
.map((id) => state.files.byId[id])
|
||||
.find(
|
||||
// Inherited together with that verdict's confidence - the escalation
|
||||
// decision (localVerdictNeedsEscalation) is about the document, not about
|
||||
// which step produced the current bytes, so it must survive the version
|
||||
// boundary. An output that already carries its own verdict (e.g. a fresh
|
||||
// classify result) keeps it.
|
||||
const inputStubs = inputFileIds.map((id) => state.files.byId[id]);
|
||||
const verdictDonor =
|
||||
inputStubs.find(
|
||||
(s) => s?.classificationLabels && s.classificationLabels.length > 0,
|
||||
);
|
||||
) ?? inputStubs.find((s) => s?.classificationLabels !== undefined);
|
||||
|
||||
// Mark every consume output as tool-produced (the single chokepoint for
|
||||
// both versioned edits and independent artifacts like convert/split/merge)
|
||||
@@ -409,7 +409,7 @@ export function fileContextReducer(
|
||||
...stub,
|
||||
derivedFromTool: true,
|
||||
sourceFileIds,
|
||||
...(stub.classificationLabels == null && verdictDonor
|
||||
...(stub.classificationLabels === undefined && verdictDonor
|
||||
? {
|
||||
classificationLabels: verdictDonor.classificationLabels,
|
||||
classificationConfidence:
|
||||
|
||||
@@ -109,7 +109,6 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
synonyms: getSynonyms(t, "pdfTextEditor"),
|
||||
supportsAutomate: false,
|
||||
automationSettings: null,
|
||||
versionStatus: "alpha",
|
||||
},
|
||||
multiTool: {
|
||||
icon: (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { clearNotificationReadState } from "@app/hooks/useNotifications";
|
||||
import { suspendWorkbenchSession } from "@app/services/workbenchSession";
|
||||
|
||||
type SignOutFn = () => Promise<void>;
|
||||
@@ -27,6 +28,8 @@ export function useAccountLogout() {
|
||||
// inherit this workbench. Suspends writing too - signing out unmounts the
|
||||
// editor, and its flush would otherwise write the record straight back.
|
||||
suspendWorkbenchSession();
|
||||
// Same reason: the next person's own failures must not arrive pre-read.
|
||||
clearNotificationReadState();
|
||||
await signOut();
|
||||
} finally {
|
||||
redirectToLogin();
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { AppNotification } from "@app/services/notifications";
|
||||
import type {
|
||||
AppNotification,
|
||||
FetchedNotifications,
|
||||
} from "@app/services/notifications";
|
||||
|
||||
/**
|
||||
* The bell is mounted several times over, so what is pinned here is that they share one read: one
|
||||
@@ -20,8 +23,26 @@ vi.mock("@app/services/localFilePresence", () => ({
|
||||
hasLocalFile: (fileId: string) => hasLocalFile(fileId),
|
||||
}));
|
||||
|
||||
const { useNotifications, refreshNotificationsNow } =
|
||||
await import("@app/hooks/useNotifications");
|
||||
const {
|
||||
useNotifications,
|
||||
refreshNotificationsNow,
|
||||
clearNotificationReadState,
|
||||
} = await import("@app/hooks/useNotifications");
|
||||
|
||||
/** A fetch result. Reviewer by default, so a test says nothing about filtering unless it means to. */
|
||||
function feed(
|
||||
notifications: AppNotification[],
|
||||
viewerReviewsTeam = true,
|
||||
viewerKey: string | null = VIEWER,
|
||||
): FetchedNotifications {
|
||||
return { notifications, viewerReviewsTeam, viewerKey };
|
||||
}
|
||||
|
||||
const VIEWER = "viewer-a";
|
||||
|
||||
function readThroughKeyFor(viewerKey: string): string {
|
||||
return `stirling.notifications.readThroughAt.${viewerKey}`;
|
||||
}
|
||||
|
||||
function notification(
|
||||
id: string,
|
||||
@@ -52,12 +73,12 @@ function notification(
|
||||
describe("useNotifications", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
fetchNotifications.mockReset().mockResolvedValue([]);
|
||||
hasLocalFile.mockClear();
|
||||
fetchNotifications.mockReset().mockResolvedValue(feed([]));
|
||||
hasLocalFile.mockReset().mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("reads the list once however many bells are mounted", async () => {
|
||||
fetchNotifications.mockResolvedValue([notification("a")]);
|
||||
fetchNotifications.mockResolvedValue(feed([notification("a")]));
|
||||
|
||||
const first = renderHook(() => useNotifications());
|
||||
const second = renderHook(() => useNotifications());
|
||||
@@ -70,11 +91,13 @@ describe("useNotifications", () => {
|
||||
});
|
||||
|
||||
it("looks a document up once for the list, not once per row", async () => {
|
||||
fetchNotifications.mockResolvedValue([
|
||||
notification("a", { fileId: "f-1" }),
|
||||
notification("b", { fileId: "f-1" }),
|
||||
notification("c", { fileId: "f-2" }),
|
||||
]);
|
||||
fetchNotifications.mockResolvedValue(
|
||||
feed([
|
||||
notification("a", { fileId: "f-1" }),
|
||||
notification("b", { fileId: "f-1" }),
|
||||
notification("c", { fileId: "f-2" }),
|
||||
]),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useNotifications());
|
||||
|
||||
@@ -83,20 +106,21 @@ describe("useNotifications", () => {
|
||||
});
|
||||
|
||||
it("looks up an attended run's document but never an unattended run's", async () => {
|
||||
// Asking storage about a source's hash can only miss, and would then be shown as "not on this
|
||||
// device" about a document that never was.
|
||||
fetchNotifications.mockResolvedValue([
|
||||
notification("attended", {
|
||||
origin: "POLICY",
|
||||
sourceId: null,
|
||||
fileId: "editor-file-1",
|
||||
}),
|
||||
notification("unattended", {
|
||||
origin: "POLICY",
|
||||
sourceId: "src-s3-invoices",
|
||||
fileId: "hashed-identity",
|
||||
}),
|
||||
]);
|
||||
// Only an attended row names a reference this browser could resolve; a source's hash misses.
|
||||
fetchNotifications.mockResolvedValue(
|
||||
feed([
|
||||
notification("attended", {
|
||||
origin: "POLICY",
|
||||
sourceId: null,
|
||||
fileId: "editor-file-1",
|
||||
}),
|
||||
notification("unattended", {
|
||||
origin: "POLICY",
|
||||
sourceId: "src-s3-invoices",
|
||||
fileId: "hashed-identity",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useNotifications());
|
||||
|
||||
@@ -113,6 +137,70 @@ describe("useNotifications", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hides a member's row whose document is not in this browser, keeps the one that is", async () => {
|
||||
hasLocalFile.mockImplementation((id: string) =>
|
||||
Promise.resolve(id === "here"),
|
||||
);
|
||||
fetchNotifications.mockResolvedValue(
|
||||
feed(
|
||||
[
|
||||
notification("gone", { fileId: "gone" }),
|
||||
notification("kept", { fileId: "here" }),
|
||||
],
|
||||
false,
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useNotifications());
|
||||
|
||||
await waitFor(() => expect(result.current.notifications).toHaveLength(1));
|
||||
expect(result.current.notifications[0].id).toBe("kept");
|
||||
// The hidden row is not news either: it must not light the badge.
|
||||
expect(result.current.unreadCount).toBe(1);
|
||||
});
|
||||
|
||||
it("hides a member's unattended row even when its id happens to be stored here", async () => {
|
||||
// A source-fed row's fileId is a content hash from another id space. Storage answering for it
|
||||
// is a collision, not the document, so the row must go on being filtered as unresolvable.
|
||||
hasLocalFile.mockResolvedValue(true);
|
||||
fetchNotifications.mockResolvedValue(
|
||||
feed(
|
||||
[
|
||||
notification("unattended", {
|
||||
sourceId: "src-s3-invoices",
|
||||
fileId: "collides-with-a-local-id",
|
||||
}),
|
||||
],
|
||||
false,
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useNotifications());
|
||||
|
||||
await waitFor(() => expect(fetchNotifications).toHaveBeenCalled());
|
||||
expect(result.current.notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("shows a reviewer both rows, document here or not", async () => {
|
||||
// A reviewer keeps a row for a file they cannot open: it is how they see a policy needs fixing.
|
||||
hasLocalFile.mockImplementation((id: string) =>
|
||||
Promise.resolve(id === "here"),
|
||||
);
|
||||
fetchNotifications.mockResolvedValue(
|
||||
feed(
|
||||
[
|
||||
notification("gone", { fileId: "gone" }),
|
||||
notification("kept", { fileId: "here" }),
|
||||
],
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useNotifications());
|
||||
|
||||
await waitFor(() => expect(result.current.notifications).toHaveLength(2));
|
||||
});
|
||||
|
||||
it("polls on one timer and stops it when the last bell unmounts", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
@@ -144,10 +232,9 @@ describe("useNotifications", () => {
|
||||
});
|
||||
|
||||
it("marks every bell read, not just the one the user opened", async () => {
|
||||
fetchNotifications.mockResolvedValue([
|
||||
notification("b"),
|
||||
notification("a"),
|
||||
]);
|
||||
fetchNotifications.mockResolvedValue(
|
||||
feed([notification("b"), notification("a")]),
|
||||
);
|
||||
const first = renderHook(() => useNotifications());
|
||||
const second = renderHook(() => useNotifications());
|
||||
await waitFor(() => expect(first.result.current.unreadCount).toBe(2));
|
||||
@@ -158,19 +245,95 @@ describe("useNotifications", () => {
|
||||
|
||||
expect(first.result.current.unreadCount).toBe(0);
|
||||
expect(second.result.current.unreadCount).toBe(0);
|
||||
expect(window.localStorage.getItem(readThroughKeyFor(VIEWER))).toBe(
|
||||
String(Date.parse("2026-08-05T00:00:00Z")),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps one viewer's read state off another's on a shared browser", async () => {
|
||||
// A timestamp is legible to whoever reads it next, so an unscoped marker would leave the
|
||||
// incoming user's older failures silently pre-read.
|
||||
fetchNotifications.mockResolvedValue(feed([notification("a")]));
|
||||
const first = renderHook(() => useNotifications());
|
||||
await waitFor(() => expect(first.result.current.unreadCount).toBe(1));
|
||||
await act(async () => first.result.current.markAllSeen());
|
||||
expect(first.result.current.unreadCount).toBe(0);
|
||||
first.unmount();
|
||||
|
||||
// Same browser, same rows, different signed-in viewer.
|
||||
fetchNotifications.mockResolvedValue(
|
||||
feed([notification("a")], true, "viewer-b"),
|
||||
);
|
||||
const second = renderHook(() => useNotifications());
|
||||
|
||||
await waitFor(() => expect(second.result.current.unreadCount).toBe(1));
|
||||
});
|
||||
|
||||
it("marks nothing when the server names no viewer", async () => {
|
||||
// Unscoped would be worse than unsaved: the next viewer here would inherit it.
|
||||
fetchNotifications.mockResolvedValue(feed([notification("a")], true, null));
|
||||
const { result } = renderHook(() => useNotifications());
|
||||
await waitFor(() => expect(result.current.unreadCount).toBe(1));
|
||||
|
||||
await act(async () => result.current.markAllSeen());
|
||||
|
||||
expect(
|
||||
window.localStorage.getItem("stirling.notifications.lastSeenId"),
|
||||
).toBe("b");
|
||||
Object.keys(window.localStorage).filter((key) =>
|
||||
key.startsWith("stirling.notifications.readThroughAt"),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps earlier rows read once the row that was newest has gone", async () => {
|
||||
fetchNotifications.mockResolvedValue(
|
||||
feed([
|
||||
notification("new", { lastSeenAt: "2026-08-05T01:00:00Z" }),
|
||||
notification("old"),
|
||||
]),
|
||||
);
|
||||
const { result } = renderHook(() => useNotifications());
|
||||
await waitFor(() => expect(result.current.unreadCount).toBe(2));
|
||||
await act(async () => result.current.markAllSeen());
|
||||
expect(result.current.unreadCount).toBe(0);
|
||||
|
||||
// It leaves the list; a marker holding its id would make the row below read as unread.
|
||||
fetchNotifications.mockResolvedValue(feed([notification("old")]));
|
||||
await act(async () => result.current.refresh());
|
||||
|
||||
await waitFor(() => expect(result.current.notifications).toHaveLength(1));
|
||||
expect(result.current.unreadCount).toBe(0);
|
||||
});
|
||||
|
||||
it("forgets the marker on sign-out, so the next user's failures are not pre-read", async () => {
|
||||
// A time is parseable whoever left it, so an inherited marker would silently mark the
|
||||
// incoming user's older rows read - the direction the id-based marker never failed in.
|
||||
fetchNotifications.mockResolvedValue(feed([notification("theirs")]));
|
||||
const leaving = renderHook(() => useNotifications());
|
||||
await waitFor(() => expect(leaving.result.current.unreadCount).toBe(1));
|
||||
await act(async () => leaving.result.current.markAllSeen());
|
||||
expect(leaving.result.current.unreadCount).toBe(0);
|
||||
|
||||
clearNotificationReadState();
|
||||
leaving.unmount();
|
||||
expect(window.localStorage.getItem(readThroughKeyFor(VIEWER))).toBeNull();
|
||||
|
||||
// The next user's own row is older than the marker that was just cleared.
|
||||
fetchNotifications.mockResolvedValue(
|
||||
feed([notification("mine", { lastSeenAt: "2026-08-04T00:00:00Z" })]),
|
||||
);
|
||||
const arriving = renderHook(() => useNotifications());
|
||||
|
||||
await waitFor(() => expect(arriving.result.current.unreadCount).toBe(1));
|
||||
});
|
||||
|
||||
it("chains one fresh read behind the read in flight rather than joining it", async () => {
|
||||
// A refresh exists to observe a write the caller just made. The read in flight may have
|
||||
// started before that write, so joining it would report the world without it - and the
|
||||
// caller would wait a whole poll interval for news of their own action.
|
||||
let release: (listed: AppNotification[]) => void = () => {};
|
||||
let release: (fetched: FetchedNotifications) => void = () => {};
|
||||
fetchNotifications.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<AppNotification[]>((resolve) => {
|
||||
new Promise<FetchedNotifications>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
@@ -180,7 +343,7 @@ describe("useNotifications", () => {
|
||||
|
||||
// A refresh from a row, twice over, and a second bell mounting - all mid-read. The
|
||||
// refreshes share ONE chained read; the mount joins what is already there.
|
||||
fetchNotifications.mockResolvedValue([notification("a")]);
|
||||
fetchNotifications.mockResolvedValue(feed([notification("a")]));
|
||||
act(() => {
|
||||
first.result.current.refresh();
|
||||
first.result.current.refresh();
|
||||
@@ -189,7 +352,7 @@ describe("useNotifications", () => {
|
||||
expect(fetchNotifications).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The stale read lands empty; the chained fresh read is what delivers the row.
|
||||
await act(async () => release([]));
|
||||
await act(async () => release(feed([])));
|
||||
await waitFor(() => expect(fetchNotifications).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() =>
|
||||
expect(first.result.current.notifications).toHaveLength(1),
|
||||
@@ -203,17 +366,17 @@ describe("useNotifications", () => {
|
||||
expect(hook.result.current.unreadCount).toBe(0);
|
||||
|
||||
// The failure report chain: row recorded server-side, then the re-read.
|
||||
fetchNotifications.mockResolvedValue([notification("a")]);
|
||||
fetchNotifications.mockResolvedValue(feed([notification("a")]));
|
||||
act(() => refreshNotificationsNow());
|
||||
|
||||
await waitFor(() => expect(hook.result.current.unreadCount).toBe(1));
|
||||
});
|
||||
|
||||
it("still lands the row when the refresh races a poll read already in flight", async () => {
|
||||
let releaseStale: (listed: AppNotification[]) => void = () => {};
|
||||
let releaseStale: (fetched: FetchedNotifications) => void = () => {};
|
||||
fetchNotifications.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<AppNotification[]>((resolve) => {
|
||||
new Promise<FetchedNotifications>((resolve) => {
|
||||
releaseStale = resolve;
|
||||
}),
|
||||
);
|
||||
@@ -223,23 +386,23 @@ describe("useNotifications", () => {
|
||||
|
||||
// The failure is recorded while a poll's read is still in flight, then its refresh fires.
|
||||
// Joining that stale read would miss the row until the next poll interval.
|
||||
fetchNotifications.mockResolvedValue([notification("a")]);
|
||||
fetchNotifications.mockResolvedValue(feed([notification("a")]));
|
||||
act(() => refreshNotificationsNow());
|
||||
await act(async () => releaseStale([]));
|
||||
await act(async () => releaseStale(feed([])));
|
||||
|
||||
await waitFor(() => expect(hook.result.current.unreadCount).toBe(1));
|
||||
});
|
||||
|
||||
it("keeps its own list rather than one left by a bell that has gone", async () => {
|
||||
fetchNotifications.mockResolvedValue([notification("a")]);
|
||||
fetchNotifications.mockResolvedValue(feed([notification("a")]));
|
||||
const first = renderHook(() => useNotifications());
|
||||
await waitFor(() =>
|
||||
expect(first.result.current.notifications).toHaveLength(1),
|
||||
);
|
||||
first.unmount();
|
||||
|
||||
// It must not show the old row while its own read is in flight.
|
||||
fetchNotifications.mockResolvedValue([]);
|
||||
// The next bell must not show the old row while its own read is still in flight.
|
||||
fetchNotifications.mockResolvedValue(feed([]));
|
||||
const second = renderHook(() => useNotifications());
|
||||
|
||||
expect(second.result.current.notifications).toHaveLength(0);
|
||||
|
||||
@@ -12,25 +12,62 @@ import { hasLocalFile } from "@app/services/localFilePresence";
|
||||
|
||||
// TODO: read state is per-browser. Move it server-side when notifications get their own table.
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
const SEEN_STORAGE_KEY = "stirling.notifications.lastSeenId";
|
||||
const SEEN_STORAGE_KEY_PREFIX = "stirling.notifications.readThroughAt";
|
||||
|
||||
function readLastSeenId(): string | null {
|
||||
/**
|
||||
* Scoped to the viewer the server named, because a timestamp is legible to whoever reads it next: an
|
||||
* unscoped marker left by the previous user of a shared browser would silently pre-read the
|
||||
* incoming user's older failures. Null while the viewer is unknown, which reads as nothing marked.
|
||||
*/
|
||||
function seenStorageKey(viewerKey: string | null): string | null {
|
||||
return viewerKey ? `${SEEN_STORAGE_KEY_PREFIX}.${viewerKey}` : null;
|
||||
}
|
||||
|
||||
/** A time, not an id: an id points at nothing once its row leaves the list. */
|
||||
function orderedAt(notification: AppNotification): number {
|
||||
return Date.parse(notification.lastSeenAt);
|
||||
}
|
||||
|
||||
function readReadThrough(viewerKey: string | null): number | null {
|
||||
const key = seenStorageKey(viewerKey);
|
||||
if (!key) return null;
|
||||
try {
|
||||
return window.localStorage.getItem(SEEN_STORAGE_KEY);
|
||||
const stored = Number(window.localStorage.getItem(key));
|
||||
return Number.isFinite(stored) && stored > 0 ? stored : null;
|
||||
} catch {
|
||||
// Private mode: everything reads as unseen, which errs towards showing failures.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeLastSeenId(id: string): void {
|
||||
function writeReadThrough(viewerKey: string | null, at: number): void {
|
||||
const key = seenStorageKey(viewerKey);
|
||||
// Unscoped would be worse than unsaved: the next viewer here would inherit it.
|
||||
if (!key) return;
|
||||
try {
|
||||
window.localStorage.setItem(SEEN_STORAGE_KEY, id);
|
||||
window.localStorage.setItem(key, String(at));
|
||||
} catch {
|
||||
// The marker just will not survive a reload.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget how far the departing reader got. The marker is scoped to its viewer, so this is belt to
|
||||
* that brace: it also covers a sign-out on a build where the server names no viewer, and it drops
|
||||
* the in-memory marker so the bell does not answer for them until the next read says who is here.
|
||||
*/
|
||||
export function clearNotificationReadState(): void {
|
||||
const key = seenStorageKey(snapshot.viewerKey);
|
||||
if (key) {
|
||||
try {
|
||||
window.localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Nothing to clear that a read could trust anyway.
|
||||
}
|
||||
}
|
||||
publish({ ...snapshot, readThroughAt: null, viewerKey: null });
|
||||
}
|
||||
|
||||
export interface NotificationDocumentState {
|
||||
hasLocalFile: boolean;
|
||||
}
|
||||
@@ -51,13 +88,17 @@ interface NotificationsSnapshot {
|
||||
notifications: AppNotification[];
|
||||
/** Keyed by fileId, so several rows about one document cost one lookup. */
|
||||
documents: Record<string, NotificationDocumentState>;
|
||||
lastSeenId: string | null;
|
||||
/** Everything up to and including this time has been read. Epoch millis, never a row id. */
|
||||
readThroughAt: number | null;
|
||||
/** Who the marker belongs to. Null until a read says, so nothing is marked on their behalf. */
|
||||
viewerKey: string | null;
|
||||
}
|
||||
|
||||
const NOTHING_LOADED: NotificationsSnapshot = {
|
||||
notifications: [],
|
||||
documents: {},
|
||||
lastSeenId: null,
|
||||
readThroughAt: null,
|
||||
viewerKey: null,
|
||||
};
|
||||
|
||||
let snapshot: NotificationsSnapshot = NOTHING_LOADED;
|
||||
@@ -87,7 +128,11 @@ function publish(next: NotificationsSnapshot): void {
|
||||
}
|
||||
|
||||
async function read(forCycle: number): Promise<void> {
|
||||
const listed = await fetchNotifications();
|
||||
const {
|
||||
notifications: listed,
|
||||
viewerReviewsTeam,
|
||||
viewerKey,
|
||||
} = await fetchNotifications();
|
||||
if (forCycle !== cycle) return;
|
||||
|
||||
const fileIds = [
|
||||
@@ -111,10 +156,27 @@ async function read(forCycle: number): Promise<void> {
|
||||
);
|
||||
if (forCycle !== cycle) return;
|
||||
|
||||
const documents = Object.fromEntries(resolved);
|
||||
// Presentation, not access: the server has already scoped these rows to the reader. Hidden
|
||||
// because every offer a member gets needs the document, so the row would only say so.
|
||||
const visible = viewerReviewsTeam
|
||||
? listed
|
||||
: listed.filter(
|
||||
// Asked rather than left to the lookup missing: an unattended row's fileId comes from
|
||||
// another id space, so a hit on one would be a collision and not the document.
|
||||
(n) =>
|
||||
isResolvableHere(n) &&
|
||||
Boolean(n.fileId && documents[n.fileId]?.hasLocalFile),
|
||||
);
|
||||
|
||||
// Read per read, not once at startup: the marker belongs to whoever the server says is
|
||||
// reading, and signing in or out changes who that is without remounting the bell.
|
||||
publish({
|
||||
...snapshot,
|
||||
notifications: listed,
|
||||
documents: Object.fromEntries(resolved),
|
||||
notifications: visible,
|
||||
documents,
|
||||
viewerKey,
|
||||
readThroughAt: readReadThrough(viewerKey),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,8 +217,9 @@ function loadFresh(): void {
|
||||
|
||||
function startPolling(): void {
|
||||
cycle += 1;
|
||||
// From disk, not memory: another tab may have moved the marker on.
|
||||
snapshot = { ...NOTHING_LOADED, lastSeenId: readLastSeenId() };
|
||||
// Nothing read until the first read names the viewer, since the marker is theirs and not this
|
||||
// browser's. Everything counts as unread until then, which errs towards showing failures.
|
||||
snapshot = NOTHING_LOADED;
|
||||
pollTimer = window.setInterval(() => void load(), POLL_INTERVAL_MS);
|
||||
void load();
|
||||
}
|
||||
@@ -183,10 +246,15 @@ function subscribe(onStoreChange: () => void): () => void {
|
||||
}
|
||||
|
||||
function markAllSeen(): void {
|
||||
const newest = snapshot.notifications[0];
|
||||
if (!newest || snapshot.lastSeenId === newest.id) return;
|
||||
writeLastSeenId(newest.id);
|
||||
publish({ ...snapshot, lastSeenId: newest.id });
|
||||
// The newest time in the list, not the first row's, so a re-sorted list cannot under-mark.
|
||||
const newest = Math.max(
|
||||
...snapshot.notifications.map(orderedAt).filter(Number.isFinite),
|
||||
);
|
||||
if (!Number.isFinite(newest)) return;
|
||||
if (snapshot.readThroughAt !== null && newest <= snapshot.readThroughAt)
|
||||
return;
|
||||
writeReadThrough(snapshot.viewerKey, newest);
|
||||
publish({ ...snapshot, readThroughAt: newest });
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
@@ -215,18 +283,17 @@ export interface NotificationsState {
|
||||
}
|
||||
|
||||
export function useNotifications(): NotificationsState {
|
||||
const { notifications, documents, lastSeenId } = useSyncExternalStore(
|
||||
const { notifications, documents, readThroughAt } = useSyncExternalStore(
|
||||
subscribe,
|
||||
getSnapshot,
|
||||
getSnapshot,
|
||||
);
|
||||
|
||||
// A marker no longer in the list means we cannot tell how far the user got, so everything reads
|
||||
// as unread rather than being silently marked seen.
|
||||
const seenIndex = lastSeenId
|
||||
? notifications.findIndex((n) => n.id === lastSeenId)
|
||||
: -1;
|
||||
const unreadCount = seenIndex === -1 ? notifications.length : seenIndex;
|
||||
// A resolved row leaves without dragging the rest back into unread. Unparseable counts as new.
|
||||
const unreadCount =
|
||||
readThroughAt === null
|
||||
? notifications.length
|
||||
: notifications.filter((n) => !(orderedAt(n) <= readThroughAt)).length;
|
||||
|
||||
return {
|
||||
notifications,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,8 @@ export async function createStirlingFilesAndStubs(
|
||||
files: File[],
|
||||
parentStub: StirlingFileStub,
|
||||
toolId: ToolId,
|
||||
/** Shown instead of the tool's name in version history (a policy passes its pipeline name). */
|
||||
label?: string,
|
||||
): Promise<{ stirlingFiles: StirlingFile[]; stubs: StirlingFileStub[] }> {
|
||||
const stirlingFiles: StirlingFile[] = [];
|
||||
const stubs: StirlingFileStub[] = [];
|
||||
@@ -22,7 +24,7 @@ export async function createStirlingFilesAndStubs(
|
||||
const processedFileMetadata = await generateProcessedFileMetadata(file);
|
||||
const childStub = createChildStub(
|
||||
parentStub,
|
||||
{ toolId, timestamp: Date.now() },
|
||||
{ toolId, timestamp: Date.now(), ...(label ? { label } : {}) },
|
||||
file,
|
||||
processedFileMetadata?.thumbnailUrl,
|
||||
processedFileMetadata,
|
||||
|
||||
@@ -12,12 +12,16 @@ export type NotificationOrigin = "TOOL" | "POLICY" | "PIPELINE";
|
||||
/** From this reader's point of view. `UNOWNED` is an unattended run: nobody holds the file. */
|
||||
export type NotificationOwnership = "MINE" | "THEIRS" | "UNOWNED";
|
||||
|
||||
/** How much of the row an action has earned; `promoteActions` turns it into a place. */
|
||||
export type NotificationActionSlot = "RESOLUTION" | "SECONDARY" | "OVERFLOW";
|
||||
|
||||
/** `id` is an open string, not a union: the server may know actions this build does not. */
|
||||
export interface NotificationActionOffer {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
/** English fallback, for a build with no copy for `labelKey`. */
|
||||
defaultLabel: string;
|
||||
slot: NotificationActionSlot;
|
||||
/** False renders no button in the bell, and a disabled one in the portal's queue. */
|
||||
enabled: boolean;
|
||||
disabledReasonKey: string | null;
|
||||
@@ -49,18 +53,49 @@ export interface AppNotification {
|
||||
|
||||
interface NotificationsResponse {
|
||||
notifications: AppNotification[];
|
||||
viewerReviewsTeam: boolean;
|
||||
viewerKey: string;
|
||||
}
|
||||
|
||||
/** Newest first. Empty rather than throwing: a bell that cannot load is an empty bell, not an error. */
|
||||
export interface FetchedNotifications {
|
||||
notifications: AppNotification[];
|
||||
/** A reviewer keeps rows whose document this browser does not hold; a member does not. */
|
||||
viewerReviewsTeam: boolean;
|
||||
/**
|
||||
* Opaque id for the signed-in viewer, for scoping this browser's read state. Null when the
|
||||
* server did not say, which must read as "cannot scope" rather than as a viewer of its own.
|
||||
*/
|
||||
viewerKey: string | null;
|
||||
}
|
||||
|
||||
/** Newest first. Empty rather than throwing, and defaulting to the least hiding. */
|
||||
export async function fetchNotifications(
|
||||
limit = 20,
|
||||
): Promise<AppNotification[]> {
|
||||
): Promise<FetchedNotifications> {
|
||||
try {
|
||||
const response = await apiClient.get<NotificationsResponse>(
|
||||
`${NOTIFICATIONS_PATH}?limit=${limit}`,
|
||||
);
|
||||
return response?.data?.notifications ?? [];
|
||||
return {
|
||||
notifications: response?.data?.notifications ?? [],
|
||||
viewerReviewsTeam: response?.data?.viewerReviewsTeam ?? true,
|
||||
viewerKey: response?.data?.viewerKey || null,
|
||||
};
|
||||
} catch {
|
||||
return [];
|
||||
return { notifications: [], viewerReviewsTeam: true, viewerKey: null };
|
||||
}
|
||||
}
|
||||
|
||||
/** Never throws: a refusal is not worth interrupting a user whose document is already fixed. */
|
||||
export async function reportNotificationResolved(
|
||||
notificationId: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await apiClient.post(
|
||||
`${NOTIFICATIONS_PATH}/${encodeURIComponent(notificationId)}/resolved`,
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -12,14 +12,20 @@ const FIXTURES = path.join(
|
||||
"../test-fixtures/classification/unlabelled",
|
||||
);
|
||||
|
||||
/** The stored policy DefaultClassificationPolicySeeder writes for a new team. */
|
||||
/**
|
||||
* What GET /api/v1/policies returns for the row an older
|
||||
* DefaultClassificationPolicySeeder wrote - i.e. one stored before editor
|
||||
* participation had its own field. `JpaPolicyStore.liftEditorConfig` derives the
|
||||
* `editor` block from the legacy `output.options` on read; the lift is additive,
|
||||
* so a real response carries both. Migration of the stored shape itself is
|
||||
* covered by JpaPolicyStoreTest, which exercises the Java the stub stands in for.
|
||||
*/
|
||||
const SEEDED_POLICY = {
|
||||
id: "seeded-classification",
|
||||
name: "Classification Policy",
|
||||
owner: "system",
|
||||
enabled: true,
|
||||
trigger: null,
|
||||
sourceIds: [],
|
||||
inputs: [],
|
||||
steps: [{ operation: "/api/v1/ai/tools/classify-and-label", parameters: {} }],
|
||||
output: {
|
||||
type: "inline",
|
||||
@@ -32,7 +38,9 @@ const SEEDED_POLICY = {
|
||||
reviewerEmail: "",
|
||||
},
|
||||
},
|
||||
outputIds: [],
|
||||
teamId: 1,
|
||||
editor: { allowed: true, runOn: "upload" },
|
||||
};
|
||||
|
||||
test("a 10-file upload wave classifies every file into its group", async ({
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import path from "path";
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
import { uploadFiles } from "@app/tests/helpers/ui-helpers";
|
||||
|
||||
// A pipeline reaches the editor auto-run through its own editor flag; a swept one must not.
|
||||
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
const SAMPLE = path.join(
|
||||
import.meta.dirname,
|
||||
"../test-fixtures/classification/unlabelled/invoice_acme.pdf",
|
||||
);
|
||||
|
||||
/** A builder-made pipeline: no categoryId, one harmless step. */
|
||||
function builderPipeline(editor: { allowed: boolean; runOn: string }) {
|
||||
return {
|
||||
id: "builder-pipeline-1",
|
||||
name: "Flatten everything",
|
||||
owner: "system",
|
||||
enabled: true,
|
||||
trigger: null,
|
||||
sourceIds: [],
|
||||
steps: [{ operation: "/api/v1/misc/flatten", parameters: {} }],
|
||||
output: { type: "inline", options: { mode: "new_version" } },
|
||||
editor,
|
||||
teamId: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/** Install the policy list + capture every stored-policy run dispatch. */
|
||||
async function armed(page: import("@playwright/test").Page, policy: unknown) {
|
||||
const dispatched: string[] = [];
|
||||
await page.route("**/api/v1/policies", (route) =>
|
||||
route.fulfill({ json: [policy] }),
|
||||
);
|
||||
await page.route("**/api/v1/policies/*/run", (route) => {
|
||||
dispatched.push(new URL(route.request().url()).pathname);
|
||||
return route.fulfill({ json: { jobId: "job-1" } });
|
||||
});
|
||||
return dispatched;
|
||||
}
|
||||
|
||||
test("an editor pipeline set to run on upload dispatches when a file is added", async ({
|
||||
page,
|
||||
}) => {
|
||||
const dispatched = await armed(
|
||||
page,
|
||||
builderPipeline({ allowed: true, runOn: "upload" }),
|
||||
);
|
||||
|
||||
await page.goto("/editor", { waitUntil: "domcontentloaded" });
|
||||
await uploadFiles(page, SAMPLE);
|
||||
|
||||
await expect
|
||||
.poll(() => dispatched, { timeout: 15_000 })
|
||||
.toContain("/api/v1/policies/builder-pipeline-1/run");
|
||||
});
|
||||
|
||||
test("a swept pipeline never runs on editor upload", async ({ page }) => {
|
||||
const dispatched = await armed(
|
||||
page,
|
||||
builderPipeline({ allowed: false, runOn: "upload" }),
|
||||
);
|
||||
|
||||
await page.goto("/editor", { waitUntil: "domcontentloaded" });
|
||||
await uploadFiles(page, SAMPLE);
|
||||
|
||||
await page.waitForTimeout(5_000);
|
||||
expect(dispatched).toEqual([]);
|
||||
});
|
||||
|
||||
test("an editor pipeline set to run on export does not fire on upload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const dispatched = await armed(
|
||||
page,
|
||||
builderPipeline({ allowed: true, runOn: "export" }),
|
||||
);
|
||||
|
||||
await page.goto("/editor", { waitUntil: "domcontentloaded" });
|
||||
await uploadFiles(page, SAMPLE);
|
||||
|
||||
await page.waitForTimeout(5_000);
|
||||
expect(dispatched).toEqual([]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user