From 091de7db83d17eb7894501576cc1fe20b5ea0fe7 Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:13:17 -0700 Subject: [PATCH 01/12] feat(commands): fail the catalog check on unbound KEEP/ABSORB handlers (#231) The descriptor availability flag is the handler gate: CommandCatalog refuses a handler for a declared id and invoke() answers command/not-implemented, so a KEEP or ABSORB action that is still declared is a menu entry that does nothing. scripts/verify-command-catalog.py now requires every KEEP/ABSORB action to be implemented or listed in docs/shell-handler-coverage.json, the baseline that records today's 70 gaps and may only shrink. --record-ratchet rewrites it. Negative-case tests prove each rule can fail; ci.yml and check-change.py report the check as whole-tree evidence. --- .github/workflows/ci.yml | 4 + changes/gh-231-quick-coverage.md | 6 ++ docs/LOOP_SHELL_CONTRACT.md | 18 +++- docs/shell-handler-coverage.json | 78 +++++++++++++++++ scripts/agent/check-change.py | 7 ++ scripts/ci/test_command_catalog.py | 70 +++++++++++++++- scripts/verify-command-catalog.py | 129 ++++++++++++++++++++++++++++- 7 files changed, 306 insertions(+), 6 deletions(-) create mode 100644 changes/gh-231-quick-coverage.md create mode 100644 docs/shell-handler-coverage.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 743698a93..4132559b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,6 +126,10 @@ jobs: run: | python3 scripts/ci/test_correction_operation_catalog.py python3 scripts/generate-architecture-catalogs.py --check + - name: Verify command catalog handler coverage (#231) + run: | + python3 scripts/ci/test_command_catalog.py + python3 scripts/verify-command-catalog.py - name: Verify preflight check catalog and coverage backlog (#165) run: python3 scripts/ci/test_preflight_check_catalog.py - name: Verify preflight corpus coverage map (#165) diff --git a/changes/gh-231-quick-coverage.md b/changes/gh-231-quick-coverage.md new file mode 100644 index 000000000..c54ce14be --- /dev/null +++ b/changes/gh-231-quick-coverage.md @@ -0,0 +1,6 @@ +# Restore Quick GUI coverage: handler-coverage contract gate (epic #231) + +Category: added +Audience: developers +Breaking-Change: no +Summary: `docs/shell-handler-coverage.json` records the KEEP and ABSORB commands the Quick shell has not bound a handler for yet (70 at b08e2183). `scripts/verify-command-catalog.py` now fails when a KEEP or ABSORB action is unbound without a ratchet entry, when a recorded gap stays after its handler lands, or when a baseline entry is not a KEEP or ABSORB command, so the count cannot regress and the list can only shrink; `--record-ratchet` rewrites the baseline from the contract. Negative-case tests in `scripts/ci/test_command_catalog.py` prove each rule can fail, the check runs as its own step in `ci.yml`'s source_integrity job, `scripts/agent/check-change.py` reports the check and its cases as whole-tree evidence, and `docs/LOOP_SHELL_CONTRACT.md` documents the gate. diff --git a/docs/LOOP_SHELL_CONTRACT.md b/docs/LOOP_SHELL_CONTRACT.md index c575b3553..54af8a381 100644 --- a/docs/LOOP_SHELL_CONTRACT.md +++ b/docs/LOOP_SHELL_CONTRACT.md @@ -197,9 +197,16 @@ so menus, shortcuts, and routing are complete, and invoking it returns the typed `implemented` requires a real capability and a registered handler; invoking an ID that is not in the contract is reported as a routing error rather than ignored. -`scripts/verify-command-catalog.py` checks the block, and checks shortcut parity -against `PDFActionManager::initActions` so the catalog cannot become a second -command truth wearing the first one's ID set. +A KEEP or ABSORB command, though, may not sit `declared` unrecorded: both +dispositions reach the menus, and a menu entry that answers `not-implemented` is +a gap (epic #231). `docs/shell-handler-coverage.json` records each remaining +unbound KEEP or ABSORB command, and the list may only shrink. Binding a handler +removes its entry with the descriptor flipped to `implemented`. ADVANCED +commands are release-profile gated and owe no handler to the release shell. + +`scripts/verify-command-catalog.py` checks the block, the handler-coverage +ratchet, and shortcut parity against `PDFActionManager::initActions` so the +catalog cannot become a second command truth wearing the first one's ID set. The Quick shell policy is self-authoritative; the retired migration form is not a runtime or documentation dependency. @@ -241,6 +248,11 @@ The command descriptors have their own check: python scripts/verify-command-catalog.py ``` +It also enforces the handler-coverage ratchet: every KEEP or ABSORB action is +`implemented` or listed in `docs/shell-handler-coverage.json`, the recorded gaps +have not moved in either direction, and the first run of the check records the +current gap set (`--record-ratchet` rewrites the baseline). + `UnitTestsDocumentFacade` covers the runtime side — catalog loading, invocation and cancellation, and the document lifecycle — with no `QWidget` and no QML engine. diff --git a/docs/shell-handler-coverage.json b/docs/shell-handler-coverage.json new file mode 100644 index 000000000..0ca39c964 --- /dev/null +++ b/docs/shell-handler-coverage.json @@ -0,0 +1,78 @@ +{ + "schema_version": 1, + "issue": 231, + "policy": "docs/loop-shell-actions.json", + "rationale": "KEEP and ABSORB commands the Quick shell has not bound a handler for yet. The descriptor availability flag is the gate: CommandCatalog::setHandler refuses a declared id, so an unbound KEEP or ABSORB action is a menu entry that does nothing. This list may only shrink. Binding a handler removes its id here with the descriptor flipped to implemented; a new KEEP or ABSORB action that arrives unbound fails scripts/verify-command-catalog.py until it is either bound or deliberately recorded here. An empty list is the epic #231 contract gate.", + "unbound": [ + "actionAutomaticDocumentRefresh", + "actionBleedFixup", + "actionBookmarkAutoGenerate", + "actionBookmarkExport", + "actionBookmarkImport", + "actionBookmarkPage", + "actionClearRecentFileHistory", + "actionColorBitonal", + "actionColorCustom", + "actionColorGrayscale", + "actionColorHighContrast", + "actionColorInvert", + "actionCopyText", + "actionCreateBitonalDocument", + "actionCreateEllipse", + "actionCreateFreehandCurve", + "actionCreateHyperlink", + "actionCreateHyperlinkToThisPDFFit", + "actionCreateHyperlinkToThisPDFFitB", + "actionCreateHyperlinkToThisPDFFitBH", + "actionCreateHyperlinkToThisPDFFitBV", + "actionCreateHyperlinkToThisPDFFitH", + "actionCreateHyperlinkToThisPDFFitR", + "actionCreateHyperlinkToThisPDFFitV", + "actionCreateHyperlinkToThisPDFXYZ", + "actionCreateHyperlinkToThisPDFXYZInheritZoom", + "actionCreatePolygon", + "actionCreatePolyline", + "actionCreateRectangle", + "actionCreateStraightLine", + "actionCreateTextHighlight", + "actionCreateTextSquiggly", + "actionCreateTextStrikeout", + "actionCreateTextUnderline", + "actionDeselectText", + "actionEncryption", + "actionExtractImage", + "actionFirstPageOnRightSide", + "actionGoToNextLine", + "actionGoToPreviousLine", + "actionGotoNextBookmark", + "actionGotoPreviousBookmark", + "actionInlineText", + "actionInsertPageNumbers", + "actionOptimize", + "actionOptimizeImages", + "actionPageGeometry", + "actionPrint", + "actionRedo", + "actionRemoveExternalLinks", + "actionRenderOptionAntialiasing", + "actionRenderOptionDisplayAnnotations", + "actionRenderOptionIgnoreOptionalContentSettings", + "actionRenderOptionSmoothPictures", + "actionRenderOptionTextAntialiasing", + "actionRender_to_Images", + "actionSanitize", + "actionScreenshot", + "actionSelectTable", + "actionSelectText", + "actionSelectTextAll", + "actionSend_by_E_Mail", + "actionStickyNoteComment", + "actionStickyNoteHelp", + "actionStickyNoteInsert", + "actionStickyNoteKey", + "actionStickyNoteNewParagraph", + "actionStickyNoteNote", + "actionStickyNoteParagraph", + "actionUndo" + ] +} diff --git a/scripts/agent/check-change.py b/scripts/agent/check-change.py index 4d89571c9..ddf6a46a9 100644 --- a/scripts/agent/check-change.py +++ b/scripts/agent/check-change.py @@ -416,6 +416,13 @@ def main() -> int: [python, "-m", "unittest", "scripts.ci.test_check_governance_registers", "scripts.ci.test_check_loop_identity", "scripts.github.test_sync_milestones", "-q"], ROOT, args.dry_run) + # The command catalog's handler-coverage ratchet (epic #231): the check is + # whole-tree, so it runs on every change like source_integrity does. + add_result(evidence, "command_catalog", + [python, "scripts/verify-command-catalog.py"], ROOT, args.dry_run) + add_result(evidence, "command_catalog_cases", + [python, "-m", "unittest", "scripts.ci.test_command_catalog", "-q"], + ROOT, args.dry_run) # Whole-tree contract guards: they audit invariants of the tree, not just the # diff, so they run on every change like source_integrity does. add_result( diff --git a/scripts/ci/test_command_catalog.py b/scripts/ci/test_command_catalog.py index 51ec9ecec..13c6fcf62 100644 --- a/scripts/ci/test_command_catalog.py +++ b/scripts/ci/test_command_catalog.py @@ -128,21 +128,35 @@ def __init__( policy: dict | None = None, main_window: str = MAIN_WINDOW, controller: str = CONTROLLER, + ratchet_unbound: list[str] | None = None, ): self.root = Path(stack.enterContext(tempfile.TemporaryDirectory())) self.policy_path = self.root / "loop-shell-actions.json" + self.ratchet_path = self.root / "shell-handler-coverage.json" self.main_window_path = self.root / "pdfeditormainwindow.cpp" self.controller_path = self.root / "pdfprogramcontroller.cpp" - self.write(policy if policy is not None else copy.deepcopy(POLICY)) + effective_policy = policy if policy is not None else copy.deepcopy(POLICY) + self.write(effective_policy) + if ratchet_unbound is None: + ratchet_unbound = verifier.unbound_actions(effective_policy) + self.write_ratchet(ratchet_unbound) self.main_window_path.write_text(main_window, encoding="utf-8") self.controller_path.write_text(controller, encoding="utf-8") def write(self, policy: dict) -> None: self.policy_path.write_text(json.dumps(policy), encoding="utf-8") + def write_ratchet(self, unbound: list[str]) -> None: + self.ratchet_path.write_text( + json.dumps({"schema_version": 1, "unbound": unbound}), encoding="utf-8" + ) + def run(self) -> list[str]: return verifier.validate_catalog( - self.policy_path, self.main_window_path, self.controller_path + self.policy_path, + self.main_window_path, + self.controller_path, + self.ratchet_path, ) @@ -211,6 +225,58 @@ def test_unexpected_implemented_command_is_rejected(self): errors = self.mutate("actionAbout", availability="implemented", capability="none") self.assertTrue(any("without a registered handler" in error for error in errors), errors) + def test_unrecorded_unbound_command_is_rejected(self): + errors = CatalogFixture(self, ratchet_unbound=[]).run() + self.assertTrue( + any( + "actionBleedFixup" in error and "ratchet baseline" in error + for error in errors + ), + errors, + ) + + def test_baseline_entry_with_a_handler_is_rejected(self): + errors = CatalogFixture( + self, ratchet_unbound=["actionBleedFixup", "actionOpen"] + ).run() + self.assertTrue( + any( + "actionOpen" in error and "bound handler" in error + for error in errors + ), + errors, + ) + + def test_baseline_entry_for_unknown_command_is_rejected(self): + errors = CatalogFixture( + self, ratchet_unbound=["actionBleedFixup", "actionGhost"] + ).run() + self.assertTrue(any("actionGhost" in error for error in errors), errors) + + def test_baseline_entry_for_advanced_command_is_rejected(self): + errors = CatalogFixture( + self, ratchet_unbound=["actionAbout", "actionBleedFixup"] + ).run() + self.assertTrue( + any( + "actionAbout" in error and "KEEP" in error for error in errors + ), + errors, + ) + + def test_recording_writes_the_current_gap_set(self): + fixture = CatalogFixture(self, ratchet_unbound=[]) + self.assertTrue(fixture.run(), "an empty baseline must fail while a gap exists") + unbound = verifier.record_ratchet(fixture.policy_path, fixture.ratchet_path) + self.assertEqual(unbound, ["actionBleedFixup"]) + self.assertEqual(fixture.run(), []) + + def test_unsorted_or_duplicated_baseline_is_reported(self): + fixture = CatalogFixture(self, ratchet_unbound=["actionBleedFixup"]) + fixture.write_ratchet(["actionBleedFixup", "actionBleedFixup"]) + with self.assertRaises(verifier.ContractError): + fixture.run() + def test_downgrading_an_implemented_command_is_rejected(self): errors = self.mutate("actionOpen", availability="declared", parameters=[]) self.assertTrue(any("no 'implemented' availability" in error for error in errors), errors) diff --git a/scripts/verify-command-catalog.py b/scripts/verify-command-catalog.py index e31cb6144..25115a0a8 100644 --- a/scripts/verify-command-catalog.py +++ b/scripts/verify-command-catalog.py @@ -12,6 +12,15 @@ disagreed with those assignments would be a second command truth wearing the first one's ID set, so both files are parsed and compared here. +The descriptor `availability` flag is the handler gate. CommandCatalog refuses +to register a handler for an id the contract marks declared, and invoke() +rejects such an id as `command/not-implemented`, so a KEEP or ABSORB command +whose availability is still `declared` is a menu entry that does nothing. Every +KEEP or ABSORB action must therefore be `implemented` or recorded in +docs/shell-handler-coverage.json, the ratchet baseline that may only shrink: +binding a handler removes its id there, and a new KEEP or ABSORB action cannot +arrive unbound (epic #231). + See docs/LOOP_SHELL_CONTRACT.md and architecture invariant I22. """ @@ -26,9 +35,15 @@ ROOT = Path(__file__).resolve().parents[1] POLICY_PATH = ROOT / "docs" / "loop-shell-actions.json" +RATCHET_PATH = ROOT / "docs" / "shell-handler-coverage.json" MAIN_WINDOW_PATH = ROOT / "LoopLibGui" / "pdfeditormainwindow.cpp" CONTROLLER_PATH = ROOT / "LoopLibGui" / "pdfprogramcontroller.cpp" +# The dispositions the handler-coverage ratchet covers. ADVANCED commands are +# release-profile gated and HIDE / STOP-SHIPPING commands never reach a menu, +# so none of them owes the Quick shell a handler. +RATCHET_DISPOSITIONS = frozenset({"KEEP", "ABSORB"}) + # Commands that have a handler in LoopLibInteraction (DocumentFacade for # document lifecycle, ViewportCommandBridge for page/zoom/rotate). A command # may only leave this set by being implemented somewhere else, never by being @@ -349,10 +364,102 @@ def check_implemented_set(policy: dict) -> list[str]: return errors +def unbound_actions(policy: dict) -> list[str]: + """KEEP and ABSORB actions whose descriptor has no bound handler yet.""" + return sorted( + action["id"] + for action in policy["actions"] + if action.get("disposition") in RATCHET_DISPOSITIONS + and action.get("command", {}).get("availability") != "implemented" + ) + + +def load_ratchet(path: Path) -> list[str]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except OSError as exc: + raise ContractError(f"cannot read {path.name}: {exc}") from exc + except json.JSONDecodeError as exc: + raise ContractError(f"{path.name} is not valid JSON: {exc}") from exc + + if not isinstance(payload, dict) or payload.get("schema_version") != 1: + raise ContractError(f"{path.name} schema_version must be 1") + unbound = payload.get("unbound") + if not isinstance(unbound, list) or any( + not isinstance(action_id, str) or not action_id for action_id in unbound + ): + raise ContractError(f"{path.name} must carry a string 'unbound' array") + if unbound != sorted(set(unbound)): + raise ContractError(f"{path.name} 'unbound' must be sorted and unique") + return unbound + + +def check_handler_ratchet(policy: dict, baseline: list[str]) -> list[str]: + """Every KEEP/ABSORB action is implemented, or is a recorded gap. + + The baseline is the frozen record of gaps; it is exact, not a floor. A new + KEEP or ABSORB action that arrives without a handler fails here, and so does + a baseline entry kept after its handler landed, so the count of unbound + commands can only move down with a visible edit. + """ + errors: list[str] = [] + awaiting = set(unbound_actions(policy)) + recorded = set(baseline) + + for action_id in sorted(awaiting - recorded): + errors.append( + f"{action_id}: KEEP/ABSORB command has no bound handler and is " + "missing from the ratchet baseline (docs/shell-handler-coverage.json)" + ) + + actions_by_id = { + action["id"]: action + for action in policy["actions"] + if isinstance(action, dict) and isinstance(action.get("id"), str) + } + for action_id in sorted(recorded - awaiting): + action = actions_by_id.get(action_id) + if action is None: + errors.append( + f"{action_id}: ratchet baseline names a command the catalog " + "does not carry; remove it" + ) + elif action.get("disposition") not in RATCHET_DISPOSITIONS: + errors.append( + f"{action_id}: ratchet baseline names a " + f"{action.get('disposition')} command; the ratchet covers KEEP " + "and ABSORB only; remove it" + ) + else: + errors.append( + f"{action_id}: ratchet baseline entry now has a bound handler; " + "remove it from the ratchet" + ) + return errors + + +def record_ratchet( + policy_path: Path = POLICY_PATH, ratchet_path: Path = RATCHET_PATH +) -> list[str]: + """Rewrites the ratchet baseline from the current contract.""" + policy = load_policy(policy_path) + unbound = unbound_actions(policy) + + payload: dict = {"schema_version": 1} + if ratchet_path.is_file(): + payload = json.loads(ratchet_path.read_text(encoding="utf-8")) + payload["unbound"] = unbound + ratchet_path.write_text( + json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + return unbound + + def validate_catalog( policy_path: Path = POLICY_PATH, main_window_path: Path = MAIN_WINDOW_PATH, controller_path: Path = CONTROLLER_PATH, + ratchet_path: Path = RATCHET_PATH, ) -> list[str]: """Returns every rule violation. A malformed input raises instead.""" policy = load_policy(policy_path) @@ -373,6 +480,7 @@ def validate_catalog( errors.extend(check_command_block(action_id, action["command"])) errors.extend(check_implemented_set(policy)) + errors.extend(check_handler_ratchet(policy, load_ratchet(ratchet_path))) if main_window_path.is_file() and controller_path.is_file(): enum_to_id = action_ids_by_enum(read_source(main_window_path), main_window_path) @@ -395,6 +503,11 @@ def verify() -> str: policy = load_policy(POLICY_PATH) implemented = len(IMPLEMENTED_COMMANDS | SHELL_IMPLEMENTED_COMMANDS) + unbound = unbound_actions(policy) + awaiting_note = ( + f"{len(unbound)} KEEP/ABSORB commands still await a handler " + f"(ratchet baseline {RATCHET_PATH.name})." + ) if MAIN_WINDOW_PATH.is_file() and CONTROLLER_PATH.is_file(): shortcuts = widget_shortcuts(read_source(CONTROLLER_PATH), CONTROLLER_PATH) shortcut_note = ( @@ -405,13 +518,27 @@ def verify() -> str: return ( f"Command catalog verified: {len(policy['actions'])} descriptors " f"({implemented} implemented, {len(policy['actions']) - implemented} declared); " + f"{awaiting_note} " f"{shortcut_note}" ) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - parser.parse_args(argv) + parser.add_argument( + "--record-ratchet", + action="store_true", + help="rewrite docs/shell-handler-coverage.json from the current contract", + ) + args = parser.parse_args(argv) + + if args.record_ratchet: + unbound = record_ratchet() + print( + f"Handler ratchet recorded: {len(unbound)} KEEP/ABSORB commands " + f"await a handler in {RATCHET_PATH.name}." + ) + return 0 try: print(verify()) From 604d92aed5b92e09bb0ece5486533df3fdfb7de7 Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:23:48 -0700 Subject: [PATCH 02/12] changes: add the PR1 evidence manifest for the handler-coverage ratchet (#231) --- changes/gh-231-quick-coverage.evidence.yaml | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 changes/gh-231-quick-coverage.evidence.yaml diff --git a/changes/gh-231-quick-coverage.evidence.yaml b/changes/gh-231-quick-coverage.evidence.yaml new file mode 100644 index 000000000..59a26a886 --- /dev/null +++ b/changes/gh-231-quick-coverage.evidence.yaml @@ -0,0 +1,24 @@ +format_version: 1 +kind: evidence +claims: + - id: handler-coverage-ratchet + evidence: + - unit:scripts/ci/test_command_catalog.py + - unit:agent-policy:interaction + - architecture:scripts/verify-command-catalog.py + - id: agent-policy-contracts + evidence: + - unit:scripts/agent/test_architecture_contracts.py + - architecture:scripts/agent/check-architecture.py + - architecture:scripts/agent/generate-adapters.py + - architecture:scripts/generate-architecture-catalogs.py + - id: packaging-workflows + evidence: + - packaging:linux-build + - packaging:windows-build + - security:codeql + - id: quick-and-interaction-modules + evidence: + - unit:agent-policy:quick + - unit:agent-policy:interaction +unresolved: [] From 1ccce441a1df39036b8246f19a13f5b5bd9639aa Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:27:01 -0700 Subject: [PATCH 03/12] docs(product-surface): point the signature and scanner plugin rows at the live epic (#231) The G00 provenance repair re-homed the absorbed plugin rows; these two ADVANCED rows were left null while their replacement surfaces (signing in loop-fix, the scanner document workflow) are still pending, so no live issue owned them. --- docs/product-surface.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/product-surface.json b/docs/product-surface.json index ddc020aec..cabe678ea 100644 --- a/docs/product-surface.json +++ b/docs/product-surface.json @@ -298,7 +298,7 @@ "replacement_surface": "loop-fix", "dependencies": ["LoopEditor"], "validation_evidence": "docs/generated/phase5-widgets-disposition.json", - "follow_up_issue": null + "follow_up_issue": 231 }, { "id": "editor-plugin", @@ -358,7 +358,7 @@ "replacement_surface": "loop-document", "dependencies": ["LoopEditor"], "validation_evidence": "docs/generated/phase5-widgets-disposition.json", - "follow_up_issue": null + "follow_up_issue": 231 }, { "id": "audiobook-plugin", From 3d24b6f8839917a0c01a8327827c716a8922edbd Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:30:08 -0700 Subject: [PATCH 04/12] feat(quick): measure and select/copy/capture on the Quick canvas (#238, #232) Text and Measure join the closed InteractionTool vocabulary as their own InteractionKinds: a text selection is a typed, revision-fenced TextSelection resolved through Core's text layout in reading order, and a measurement is a typed revision-fenced Measurement in production units that never becomes a DragSession. The nine actionSelect*/CopyText/GoTo*Line/Screenshot/ExtractImage commands are bound; screenshot and image extraction write through the atomic writer and refuse to overwrite; a page without a text layer reports text/no-text-layer instead of copying an empty string. --- LoopEditor/documentviewsession.cpp | 16 + LoopEditor/documentviewsession.h | 5 + LoopEditor/editorhost.cpp | 270 +++++++++- LoopEditor/editorhost.h | 29 ++ LoopEditor/qml/Main.qml | 32 ++ LoopEditor/qml/ShellToolBar.qml | 32 ++ LoopLibInteraction/CMakeLists.txt | 6 + LoopLibInteraction/sources/canvassnapshot.cpp | 49 ++ LoopLibInteraction/sources/canvassnapshot.h | 7 + .../sources/interactioncontroller.cpp | 435 ++++++++++++++++ .../sources/interactioncontroller.h | 97 ++++ .../sources/interactionstate.cpp | 130 +++++ LoopLibInteraction/sources/interactionstate.h | 153 +++++- LoopLibInteraction/sources/overlaybuilder.cpp | 74 ++- LoopLibInteraction/sources/overlayframe.cpp | 2 + LoopLibInteraction/sources/overlayframe.h | 12 +- LoopLibInteraction/sources/pageunitsource.h | 52 ++ .../sources/pdfdocumentimageextractor.cpp | 152 ++++++ .../sources/pdfdocumentimageextractor.h | 74 +++ .../pdfdocumenttextselectionsource.cpp | 364 +++++++++++++ .../sources/pdfdocumenttextselectionsource.h | 84 +++ .../sources/textselectionsource.h | 88 ++++ .../sources/viewportcontroller.cpp | 28 + .../sources/viewportcontroller.h | 9 +- UnitTests/tst_documentviewsessiontest.cpp | 327 ++++++++++++ UnitTests/tst_interactioncontrollertest.cpp | 492 +++++++++++++++++- UnitTests/tst_overlayframetest.cpp | 71 +++ UnitTests/tst_quickaccessibilitytest.cpp | 28 + UnitTests/tst_quickcanvastest.cpp | 252 +++++++++ docs/INTERACTION_CONTRACT.md | 35 +- docs/LOOP_SHELL_CONTRACT.md | 2 +- docs/loop-shell-actions.json | 36 +- docs/shell-handler-coverage.json | 9 - scripts/verify-command-catalog.py | 9 + .../qml/Main.qml | 32 ++ .../qml/ShellToolBar.qml | 32 ++ 36 files changed, 3484 insertions(+), 41 deletions(-) create mode 100644 LoopLibInteraction/sources/pageunitsource.h create mode 100644 LoopLibInteraction/sources/pdfdocumentimageextractor.cpp create mode 100644 LoopLibInteraction/sources/pdfdocumentimageextractor.h create mode 100644 LoopLibInteraction/sources/pdfdocumenttextselectionsource.cpp create mode 100644 LoopLibInteraction/sources/pdfdocumenttextselectionsource.h create mode 100644 LoopLibInteraction/sources/textselectionsource.h diff --git a/LoopEditor/documentviewsession.cpp b/LoopEditor/documentviewsession.cpp index 7ad871c89..3a8f0a012 100644 --- a/LoopEditor/documentviewsession.cpp +++ b/LoopEditor/documentviewsession.cpp @@ -5,6 +5,8 @@ #include "interactioncontroller.h" #include "overlaybuilder.h" #include "pagesurfacecoordinator.h" +#include "pdfdocumentimageextractor.h" +#include "pdfdocumenttextselectionsource.h" #include "pdfdocumentsession.h" #include "pdfpagecachebudget.h" @@ -104,6 +106,16 @@ DocumentViewSession::DocumentViewSession(QObject* parent) : *m_hitTest, *m_overlays, this); + m_textSelectionSource = std::make_unique(&m_context); + m_interaction->setTextSelectionSource(m_textSelectionSource.get()); + m_imageExtractor = std::make_unique(&m_context); + + // The revision moving under a measurement or a text selection is the + // conflict path: both are dropped rather than carried onto geometry the + // operator never saw. The session owns the stack, so it owns this wiring. + connect(&m_context, &pdf::PDFDocumentContext::revisionChanged, this, + [this](const pdf::PDFRevisionIdentity&, const pdf::PDFRevisionIdentity&) + { m_interaction->handleRevisionChanged(); }); m_viewport.setPageLayout(pdfinteraction::PageLayout::SinglePage); applyInitialViewportScreenMetrics(m_viewport); @@ -205,6 +217,9 @@ void DocumentViewSession::prepareDocumentView() m_interaction->invalidate(); m_geometry = std::make_unique(&m_context); m_viewport.setGeometrySource(m_geometry.get()); + // The geometry source answers the page's /UserUnit scale too, so the + // measure tool reads the same page facts the viewport laid out. + m_interaction->setPageUnitSource(m_geometry.get()); m_viewport.invalidateLayout(); m_surfaces->setDocumentKey(m_revisionSource->documentKey()); @@ -223,6 +238,7 @@ void DocumentViewSession::prepareDocumentView() void DocumentViewSession::clearDocumentView() { m_interaction->invalidate(); + m_interaction->setPageUnitSource(nullptr); m_viewport.setGeometrySource(nullptr); m_geometry.reset(); m_surfaces->invalidate(m_facade->currentRevision()); diff --git a/LoopEditor/documentviewsession.h b/LoopEditor/documentviewsession.h index 9de04b912..5de1d1114 100644 --- a/LoopEditor/documentviewsession.h +++ b/LoopEditor/documentviewsession.h @@ -31,6 +31,8 @@ class OverlayBuilder; class PageSurfaceCoordinator; class PDFDocumentContextSource; class PDFDocumentPageGeometrySource; +class PDFDocumentTextSelectionSource; +class PDFDocumentImageExtractor; } /// Owns the document-bound view machinery used by EditorHost. @@ -65,6 +67,7 @@ class DocumentViewSession final : public QObject pdfinteraction::HitTestDispatcher* hitTest() const noexcept { return m_hitTest.get(); } pdfinteraction::OverlayBuilder* overlays() const noexcept { return m_overlays.get(); } pdfinteraction::InteractionController* interaction() const noexcept { return m_interaction.get(); } + pdfinteraction::PDFDocumentImageExtractor* imageExtractor() const noexcept { return m_imageExtractor.get(); } pdfinteraction::ViewportCommandBridge& commandBridge() noexcept { return m_commandBridge; } pdfinteraction::PageBoxHitTestSource& pageBoxSource() noexcept { return m_pageBoxSource; } @@ -131,6 +134,8 @@ class DocumentViewSession final : public QObject std::unique_ptr m_hitTest; std::unique_ptr m_overlays; std::unique_ptr m_interaction; + std::unique_ptr m_textSelectionSource; + std::unique_ptr m_imageExtractor; pdfinteraction::ViewportCommandBridge m_commandBridge; pdfinteraction::PageBoxHitTestSource m_pageBoxSource; qsizetype m_cacheLimit = DefaultCacheLimit; // total authority (partitioned via PDFPageCacheBudget) diff --git a/LoopEditor/editorhost.cpp b/LoopEditor/editorhost.cpp index f335a51fe..e07b1762d 100644 --- a/LoopEditor/editorhost.cpp +++ b/LoopEditor/editorhost.cpp @@ -43,6 +43,7 @@ #include "interactiontarget.h" #include "pdfdocumentsession.h" +#include "pdfdocumentimageextractor.h" #include "pdfsecurityhandler.h" #include "pdfdocumentwriter.h" #include "pdfartifactstore.h" @@ -60,6 +61,7 @@ #include #include #include +#include #include #include #include @@ -546,6 +548,157 @@ bool EditorHost::setActiveTool(const QString& toolId) return true; } +bool EditorHost::copySelectedText() +{ + pdfinteraction::InteractionController* interaction = m_session->interaction(); + if (!interaction) + { + return false; + } + + // The refusal is typed and named, and the clipboard is left untouched: an + // empty string is never copied as success. + const pdfinteraction::TextCopyResult result = interaction->selectedText(); + if (!result.copied) + { + announceDocumentState(tr("Copy refused: %1").arg(result.refusalReason)); + return false; + } + + QGuiApplication::clipboard()->setText(result.text); + announceDocumentState(tr("Copied %n character(s).", "", result.text.size())); + return true; +} + +QImage EditorHost::composeVisibleCanvasImage() const +{ + pdfinteraction::PageSurfaceCoordinator* surfaces = m_session->surfaces(); + if (!surfaces || !hasDocument()) + { + return QImage(); + } + + const pdfinteraction::CanvasSnapshot& snapshot = surfaces->snapshot(); + if (snapshot.isEmpty() || !snapshot.token.revision.isValid() || + snapshot.token.revision != m_session->facade().currentRevision()) + { + return QImage(); + } + + return pdfinteraction::composeCanvasImage(snapshot, + m_session->viewport().viewportRect(), + m_session->viewport().devicePixelRatio()); +} + +bool EditorHost::captureCanvasFileUrl(const QUrl& url) +{ + const QString path = url.toLocalFile(); + if (path.isEmpty()) + { + announceDocumentState(tr("Choose a local file to save the screenshot to.")); + return false; + } + + const QImage image = composeVisibleCanvasImage(); + if (image.isNull()) + { + announceDocumentState(tr("There is nothing on the canvas to capture.")); + return false; + } + + QByteArray png; + { + QBuffer buffer(&png); + buffer.open(QIODevice::WriteOnly); + if (!image.save(&buffer, "PNG")) + { + announceDocumentState(tr("The screenshot could not be encoded.")); + return false; + } + } + + const pdf::PDFOperationResult result = + pdf::PDFSafeFileWriter::writeData(path, png, pdf::PDFSafeFileWriter::OverwritePolicy::Fail); + if (!result) + { + announceDocumentState(tr("Could not write the screenshot: %1").arg(result.getErrorMessage())); + return false; + } + + announceDocumentState(tr("Screenshot saved.")); + return true; +} + +bool EditorHost::extractCurrentPageImagesFileUrl(const QUrl& directory) +{ + if (!hasDocument() || !m_session->imageExtractor()) + { + announceDocumentState(tr("Open a document before extracting images.")); + return false; + } + + const QString directoryPath = directory.toLocalFile(); + if (directoryPath.isEmpty() || !QFileInfo(directoryPath).isDir()) + { + announceDocumentState(tr("Choose an existing directory to extract the images into.")); + return false; + } + + QString typedError; + const QList images = m_session->imageExtractor()->extractPage(currentPage(), &typedError); + if (!typedError.isEmpty()) + { + announceDocumentState(tr("Could not extract images: %1").arg(typedError)); + return false; + } + if (images.isEmpty()) + { + announceDocumentState(tr("This page has no images to extract.")); + return false; + } + + // Guard every planned output up front: extraction must not silently clobber + // an existing file, and a run must not stop halfway through the set. + QStringList plannedOutputs; + plannedOutputs.reserve(images.size()); + for (int index = 0; index < images.size(); ++index) + { + plannedOutputs << QDir(directoryPath).filePath(QStringLiteral("page-%1-image-%2.png").arg(currentPage() + 1).arg(index + 1)); + } + + if (!pdf::PDFSafeFileWriter::findOutputConflicts(plannedOutputs, true).isEmpty()) + { + announceDocumentState(tr("Refusing to overwrite an existing file in the destination directory.")); + return false; + } + + for (int index = 0; index < images.size(); ++index) + { + QByteArray png; + { + QBuffer buffer(&png); + buffer.open(QIODevice::WriteOnly); + if (!images[index].image.save(&buffer, "PNG")) + { + announceDocumentState(tr("An extracted image could not be encoded.")); + return false; + } + } + + const pdf::PDFOperationResult result = + pdf::PDFSafeFileWriter::writeData(plannedOutputs[index], png, pdf::PDFSafeFileWriter::OverwritePolicy::Fail); + if (!result) + { + announceDocumentState(tr("Could not write %1: %2") + .arg(QFileInfo(plannedOutputs[index]).fileName(), result.getErrorMessage())); + return false; + } + } + + announceDocumentState(tr("Extracted %n image(s).", "", images.size())); + return true; +} + QObject* EditorHost::preflight() { return &m_preflight; @@ -3525,6 +3678,25 @@ void EditorHost::connectInteraction() &pdfinteraction::InteractionController::dragCompleted, this, &EditorHost::onDragCompleted); + + // Measurements and text selections are presentation state the accessible + // summary reports, so every change re-reads the summary. + connect(m_session->interaction(), + &pdfinteraction::InteractionController::measurementCompleted, + this, + [this](const pdfinteraction::Measurement& measurement) + { + announceDocumentState(tr("Measured %1.").arg(pdfinteraction::measurementReadout(measurement))); + bumpPresentation(); + }); + connect(m_session->interaction(), + &pdfinteraction::InteractionController::measurementsCleared, + this, + &EditorHost::bumpPresentation); + connect(m_session->interaction(), + &pdfinteraction::InteractionController::textSelectionChanged, + this, + &EditorHost::bumpPresentation); } void EditorHost::connectSurfaces() @@ -3587,6 +3759,58 @@ void EditorHost::registerFeatureHandlers() bind(QStringLiteral("actionProperties"), [this] { setWorkspace(LoopWorkspace::Inspect); }); + // ---- Select, copy and capture (issue #232) ------------------------------------- + // A selection command finishes Failed with a typed code when it applied + // nothing, so "the menu did nothing" is reported rather than implied. + auto bindSelectionCommand = [this](const QString& id, std::function action) + { + pdfinteraction::CommandCatalog::Handler handler; + handler.invoke = [this, action = std::move(action)](pdfinteraction::CommandInvocationId invocation, const QVariantMap&) + { + const bool applied = action(); + m_session->catalog().finishInvocation(invocation, + applied ? pdfinteraction::CommandTerminalState::Completed + : pdfinteraction::CommandTerminalState::Failed, + applied ? QString() : QStringLiteral("text/selection-refused")); + bumpPresentation(); + }; + m_session->catalog().setHandler(id, std::move(handler)); + }; + + bindSelectionCommand(QStringLiteral("actionSelectText"), [this] + { return setActiveTool(QStringLiteral("text")); }); + bindSelectionCommand(QStringLiteral("actionSelectTextAll"), [this] + { return m_session->interaction()->selectAllText(); }); + bindSelectionCommand(QStringLiteral("actionDeselectText"), [this] + { return m_session->interaction()->deselectText(); }); + bindSelectionCommand(QStringLiteral("actionSelectTable"), [this] + { return m_session->interaction()->selectTableUnderPointer(); }); + bindSelectionCommand(QStringLiteral("actionGoToNextLine"), [this] + { return m_session->interaction()->stepTextSelectionByLine(1); }); + bindSelectionCommand(QStringLiteral("actionGoToPreviousLine"), [this] + { return m_session->interaction()->stepTextSelectionByLine(-1); }); + + // Copy reports the controller's typed refusal, so a page with no text layer + // can never be copied as an empty success. + pdfinteraction::CommandCatalog::Handler copy; + copy.invoke = [this](pdfinteraction::CommandInvocationId invocation, const QVariantMap&) + { + const bool copied = copySelectedText(); + m_session->catalog().finishInvocation(invocation, + copied ? pdfinteraction::CommandTerminalState::Completed + : pdfinteraction::CommandTerminalState::Failed, + copied ? QString() : QStringLiteral("text/copy-refused")); + bumpPresentation(); + }; + m_session->catalog().setHandler(QStringLiteral("actionCopyText"), std::move(copy)); + + // Screenshot and image extraction need a destination, so the commands ask + // the shell for one and write nothing themselves. + bind(QStringLiteral("actionScreenshot"), [this] + { Q_EMIT screenshotRequested(); }); + bind(QStringLiteral("actionExtractImage"), [this] + { Q_EMIT imageExtractionRequested(); }); + pdfinteraction::CommandCatalog::Handler move; move.invoke = [this](pdfinteraction::CommandInvocationId invocation, const QVariantMap& parameters) { @@ -3616,6 +3840,20 @@ void EditorHost::refreshFeatureAvailability() availability.insert(QStringLiteral("actionFindPrevious"), hasSearchResults); availability.insert(QStringLiteral("actionFullscreenMode"), true); availability.insert(MoveSelectionCommandId, ready && m_session->facade().permissions().allowsCorrection()); + + // Select, copy and capture (issue #232). Every one of these is enabled with + // a document: Copy deliberately stays enabled without a selection so its + // typed refusal is reachable and visible, rather than the command silently + // disappearing from the menu. + for (const QString& id : { QStringLiteral("actionSelectText"), QStringLiteral("actionSelectTextAll"), + QStringLiteral("actionDeselectText"), QStringLiteral("actionSelectTable"), + QStringLiteral("actionCopyText"), QStringLiteral("actionGoToNextLine"), + QStringLiteral("actionGoToPreviousLine"), QStringLiteral("actionScreenshot"), + QStringLiteral("actionExtractImage") }) + { + availability.insert(id, ready); + } + m_session->catalog().setEnabledBatch(availability); } @@ -4181,8 +4419,36 @@ void EditorHost::updateCanvasAccessibilitySummary() const int pageNumber = currentPage() + 1; const int pages = pageCount(); const int zoomPercent = qRound(zoom() * 100.0); - m_canvas->setAccessibleDocumentSummary( - tr("Document canvas. Page %1 of %2. Zoom %3 percent.").arg(pageNumber).arg(pages).arg(zoomPercent)); + QString summary = tr("Document canvas. Page %1 of %2. Zoom %3 percent.").arg(pageNumber).arg(pages).arg(zoomPercent); + + // Measurements and text selections are readouts, so the screen reader gets + // them from the same summary the canvas already exposes. + const pdfinteraction::InteractionController* interaction = m_session->interaction(); + if (interaction) + { + const pdfinteraction::InteractionState& state = interaction->state(); + const std::optional& selection = state.textSelection(); + if (selection.has_value()) + { + if (!selection->available) + { + summary += QLatin1Char(' ') + tr("Text selection unavailable: %1").arg(selection->unavailableReason); + } + else if (!selection->text.isEmpty()) + { + summary += QLatin1Char(' ') + tr("Selected %n character(s).", "", selection->text.size()); + } + } + + if (!state.measurements().isEmpty()) + { + summary += QLatin1Char(' ') + tr("Measurements: %1. Latest: %2.") + .arg(state.measurements().size()) + .arg(pdfinteraction::measurementReadout(state.measurements().last())); + } + } + + m_canvas->setAccessibleDocumentSummary(summary); } void EditorHost::onPreflightNavigation(pdfinteraction::PreflightController::EvidenceNavigationRequest request) diff --git a/LoopEditor/editorhost.h b/LoopEditor/editorhost.h index 9cef1bf44..492803a24 100644 --- a/LoopEditor/editorhost.h +++ b/LoopEditor/editorhost.h @@ -53,6 +53,7 @@ #include "pdfoperationhistory.h" #include +#include #include #include #include @@ -440,6 +441,21 @@ class EditorHost final : public QObject /// in-flight drag with ToolChanged (#141 AC3). Q_INVOKABLE bool setActiveTool(const QString& toolId); + // ---- Select, copy and capture (issue #232) ------------------------------------- + /// Copies the canvas text selection to the clipboard. The clipboard is only + /// written when there is text to copy; a page with no text layer reports + /// that state and an empty string is never copied as success. + Q_INVOKABLE bool copySelectedText(); + + /// Writes the visible canvas region to `url` as a PNG through the atomic + /// writer, which refuses to overwrite an existing file. + Q_INVOKABLE bool captureCanvasFileUrl(const QUrl& url); + + /// Extracts the current page's images into `directory` through the atomic + /// writer. Refuses up front when any target file already exists, so a run + /// cannot half-overwrite the destination. + Q_INVOKABLE bool extractCurrentPageImagesFileUrl(const QUrl& directory); + Q_INVOKABLE QVariantList commandDescriptors() const; Q_INVOKABLE bool isCommandEnabled(const QString& commandId) const; Q_INVOKABLE quint64 invokeCommand(const QString& commandId, const QVariantMap& parameters = {}); @@ -489,6 +505,15 @@ class EditorHost final : public QObject void preflightProfileExportRequested(); void preflightProfileSaveRequested(); + /// The Screenshot command needs a destination, so the shell asks for one + /// and answers with captureCanvasFileUrl(). The command itself writes + /// nothing. + void screenshotRequested(); + + /// The Extract Image command needs a destination directory, so the shell + /// asks for one and answers with extractCurrentPageImagesFileUrl(). + void imageExtractionRequested(); + private: void connectFacade(); void connectViewport(); @@ -538,6 +563,10 @@ class EditorHost final : public QObject void applyEmptyCanvasInspectorSelection(); void setInspectionMode(QString mode); + /// The visible canvas region as one image, or a null image when no admitted + /// tile intersects it or the snapshot is stale. + QImage composeVisibleCanvasImage() const; + /// Operator review of the planned correction (#586). `fixPlanIsCurrent()` is the only /// authority on whether the reviewed plan still describes the open revision. QString fixCurrentPlanDigest() const; diff --git a/LoopEditor/qml/Main.qml b/LoopEditor/qml/Main.qml index 25b34b815..5c25e1cd7 100644 --- a/LoopEditor/qml/Main.qml +++ b/LoopEditor/qml/Main.qml @@ -158,6 +158,32 @@ ApplicationWindow { onRejected: if (host && host.focusRestoration) host.focusRestoration.restore() } + FileDialog { + id: screenshotDialog + title: qsTr("Save Screenshot") + fileMode: FileDialog.SaveFile + nameFilters: [qsTr("PNG images (*.png)")] + onAccepted: { + if (host) { + host.captureCanvasFileUrl(selectedFile) + } + if (host && host.focusRestoration) host.focusRestoration.restore() + } + onRejected: if (host && host.focusRestoration) host.focusRestoration.restore() + } + + FolderDialog { + id: extractImagesDialog + title: qsTr("Extract Images Into Folder") + onAccepted: { + if (host) { + host.extractCurrentPageImagesFileUrl(selectedFolder) + } + if (host && host.focusRestoration) host.focusRestoration.restore() + } + onRejected: if (host && host.focusRestoration) host.focusRestoration.restore() + } + FileDialog { id: preflightReportDialog title: qsTr("Export Preflight Report") @@ -228,6 +254,12 @@ ApplicationWindow { profileSaveVersionField.text = host ? host.preflightProfileDraftVersion : "" preflightProfileSaveDialog.open() } + function onScreenshotRequested() { + screenshotDialog.open() + } + function onImageExtractionRequested() { + extractImagesDialog.open() + } } TextField { diff --git a/LoopEditor/qml/ShellToolBar.qml b/LoopEditor/qml/ShellToolBar.qml index bfad8d5ae..b8ca48281 100644 --- a/LoopEditor/qml/ShellToolBar.qml +++ b/LoopEditor/qml/ShellToolBar.qml @@ -157,6 +157,38 @@ ToolBar { value: !!root.host && root.host.activeTool === "hand" } } + ToolButton { + id: textToolButton + objectName: "textToolButton" + text: qsTr("Text") + checkable: true + activeFocusOnTab: true + onClicked: if (root.host) root.host.setActiveTool("text") + Accessible.role: Accessible.RadioButton + Accessible.name: qsTr("Text selection tool") + ButtonGroup.group: toolGroup + Binding { + target: textToolButton + property: "checked" + value: !!root.host && root.host.activeTool === "text" + } + } + ToolButton { + id: measureToolButton + objectName: "measureToolButton" + text: qsTr("Measure") + checkable: true + activeFocusOnTab: true + onClicked: if (root.host) root.host.setActiveTool("measure") + Accessible.role: Accessible.RadioButton + Accessible.name: qsTr("Measure tool") + ButtonGroup.group: toolGroup + Binding { + target: measureToolButton + property: "checked" + value: !!root.host && root.host.activeTool === "measure" + } + } ToolSeparator {} ToolButton { objectName: "zoomInButton" diff --git a/LoopLibInteraction/CMakeLists.txt b/LoopLibInteraction/CMakeLists.txt index c73069f67..0aa27c2b2 100644 --- a/LoopLibInteraction/CMakeLists.txt +++ b/LoopLibInteraction/CMakeLists.txt @@ -71,6 +71,12 @@ add_library(LoopLibInteraction STATIC sources/interactiontarget.h sources/interactionstate.cpp sources/interactionstate.h + sources/textselectionsource.h + sources/pdfdocumenttextselectionsource.cpp + sources/pdfdocumenttextselectionsource.h + sources/pdfdocumentimageextractor.cpp + sources/pdfdocumentimageextractor.h + sources/pageunitsource.h sources/hittestsource.cpp sources/hittestsource.h sources/pagespatialindex.cpp diff --git a/LoopLibInteraction/sources/canvassnapshot.cpp b/LoopLibInteraction/sources/canvassnapshot.cpp index aca5c5f13..8caf10626 100644 --- a/LoopLibInteraction/sources/canvassnapshot.cpp +++ b/LoopLibInteraction/sources/canvassnapshot.cpp @@ -22,6 +22,8 @@ #include "canvassnapshot.h" +#include + namespace pdfinteraction { @@ -38,4 +40,51 @@ const CanvasTile* CanvasSnapshot::tileForPage(int pageIndex) const return nullptr; } +QImage composeCanvasImage(const CanvasSnapshot& snapshot, QRect viewportRect, qreal devicePixelRatio) +{ + const qreal ratio = devicePixelRatio > 0.0 ? devicePixelRatio : 1.0; + + QImage image(viewportRect.size() * ratio, QImage::Format_ARGB32_Premultiplied); + if (image.isNull()) + { + return image; + } + + // A capture of the visible region shows paper where a page is and the + // canvas background where one is not; the tiles carry the paper, so the + // canvas is filled with white rather than left undefined. + image.fill(Qt::white); + image.setDevicePixelRatio(ratio); + + QPainter painter(&image); + bool anyTile = false; + for (const CanvasTile& tile : snapshot.tiles) + { + if (!tile.pixels || tile.pixels->image.isNull() || tile.placedRect.isEmpty()) + { + continue; + } + + if (!QRectF(tile.placedRect).intersects(QRectF(viewportRect))) + { + continue; + } + + // The tile image is drawn into its placed rectangle in device pixels. + // A tile rendered for another resolution is scaled here exactly as the + // scene graph scales it, so the capture shows what the canvas shows. + const QRectF target(tile.placedRect.topLeft() * ratio, tile.placedRect.size() * ratio); + painter.drawImage(target, tile.pixels->image); + anyTile = true; + } + painter.end(); + + if (!anyTile) + { + return QImage(); + } + + return image; +} + } // namespace pdfinteraction diff --git a/LoopLibInteraction/sources/canvassnapshot.h b/LoopLibInteraction/sources/canvassnapshot.h index 084657789..f9fb78af5 100644 --- a/LoopLibInteraction/sources/canvassnapshot.h +++ b/LoopLibInteraction/sources/canvassnapshot.h @@ -25,6 +25,7 @@ #include "pagesurfacekey.h" +#include #include #include @@ -59,6 +60,12 @@ struct CanvasSnapshot bool isEmpty() const { return tiles.isEmpty(); } }; +/// Composes the admitted tiles that intersect `viewportRect` into one image, in +/// device pixels, over a white page background. Pure: it reads the snapshot and +/// writes nothing, so the screenshot path and its test drive identical code. +/// Returns a null image when no tile intersects the region. +QImage composeCanvasImage(const CanvasSnapshot& snapshot, QRect viewportRect, qreal devicePixelRatio); + } // namespace pdfinteraction #endif // CANVASSNAPSHOT_H diff --git a/LoopLibInteraction/sources/interactioncontroller.cpp b/LoopLibInteraction/sources/interactioncontroller.cpp index b8908edbc..5961a6ca0 100644 --- a/LoopLibInteraction/sources/interactioncontroller.cpp +++ b/LoopLibInteraction/sources/interactioncontroller.cpp @@ -24,6 +24,8 @@ #include +#include + namespace pdfinteraction { @@ -93,6 +95,22 @@ void InteractionController::setTraceRecorder(InteractionTraceRecorder* recorder) m_trace = recorder; } +void InteractionController::setTextSelectionSource(ITextSelectionSource* source) +{ + m_textSelectionSource = source; +} + +void InteractionController::setPageUnitSource(IPageUnitSource* source) +{ + m_pageUnitSource = source; +} + +bool InteractionController::fenceIsCurrent() const +{ + const RevisionFencedToken fence = token(); + return fence.revision.isValid() && (!m_revisions || m_revisions->isCurrent(fence.revision)); +} + RevisionFencedToken InteractionController::token() const { RevisionFencedToken token; @@ -162,7 +180,17 @@ void InteractionController::cancelActive(InteractionCancelReason reason) return; } + // A cancelled text-selection gesture drops the draft selection with it: an + // escape or a tool change must not leave half a selection the operator + // never finished. A completed selection is dropped by deselectText() or by + // a revision change, not by the gesture that made it. + const bool wasTextSelect = m_state.isActive(InteractionKind::TextSelect); m_state.cancel(reason); + if (wasTextSelect && m_state.clearTextSelection()) + { + Q_EMIT textSelectionChanged(); + } + Q_EMIT interactionCancelled(reason); } @@ -251,6 +279,21 @@ void InteractionController::handlePointerPress(const PointerIntent& intent) return; } + // Text and Measure own the left button the same way Hand does: they are + // tools, so what the press means is decided by the tool, not by the target + // under the pointer. + if (m_activeTool == InteractionTool::Text) + { + beginTextSelection(intent); + return; + } + + if (m_activeTool == InteractionTool::Measure) + { + beginMeasurement(intent); + return; + } + const bool selectionDidChange = m_state.setSelected(target); if (selectionDidChange) { @@ -292,6 +335,39 @@ void InteractionController::handlePointerMove(const PointerIntent& intent) return; } + if (m_state.isActive(InteractionKind::TextSelect)) + { + // A text selection stays on the page it began on, exactly as a drag + // does; resolving the page under the pointer would retarget the + // gesture the moment it crossed a page boundary. + const std::optional pagePoint = m_viewport->viewportToPagePoint(intent.positionPx, m_textGesturePage); + m_state.setPointerPosition(intent.positionPx, m_textGesturePage, pagePoint); + if (pagePoint.has_value()) + { + applyTextRange(m_textGesturePage, m_textAnchorPagePoint, *pagePoint); + } + + publishOverlay(); + return; + } + + if (m_state.isActive(InteractionKind::Measure)) + { + const std::optional pagePoint = m_viewport->viewportToPagePoint(intent.positionPx, m_measurePageIndex); + m_state.setPointerPosition(intent.positionPx, m_measurePageIndex, pagePoint); + if (pagePoint.has_value() && m_state.measurementDraft().has_value()) + { + const Measurement draft = measureBetween(m_measurePageIndex, + m_state.measurementDraft()->startPagePoint, + *pagePoint, + m_measurePointToMM); + m_state.setMeasurementDraft(draft, token()); + } + + publishOverlay(); + return; + } + if (m_state.drag().has_value()) { const int dragPage = m_state.drag()->target.pageIndex; @@ -343,6 +419,51 @@ void InteractionController::handlePointerRelease(const PointerIntent& intent) return; } + if (m_state.isActive(InteractionKind::TextSelect)) + { + // Below the hysteresis threshold a press and release is a click: it + // clears the selection instead of leaving a zero-length one behind. + const QPoint travel = intent.positionPx - m_textAnchorPx; + const bool travelled = std::hypot(qreal(travel.x()), qreal(travel.y())) > qreal(m_state.dragThresholdPx()); + + m_state.cancel(InteractionCancelReason::Explicit); + if (travelled) + { + Q_EMIT textSelectionChanged(); + } + else if (m_state.clearTextSelection()) + { + Q_EMIT textSelectionChanged(); + } + + publishOverlay(); + return; + } + + if (m_state.isActive(InteractionKind::Measure)) + { + const QPoint travel = intent.positionPx - m_measureAnchorPx; + const bool travelled = std::hypot(qreal(travel.x()), qreal(travel.y())) > qreal(m_state.dragThresholdPx()); + + if (!travelled) + { + m_state.cancel(InteractionCancelReason::Explicit); + publishOverlay(); + return; + } + + const std::optional completed = m_state.completeMeasurement(token()); + if (completed.has_value()) + { + // Presentation state only. Nothing here is routed to a command: + // a measurement is not a document edit. + Q_EMIT measurementCompleted(*completed); + } + + publishOverlay(); + return; + } + if (!m_state.drag().has_value()) { return; @@ -419,6 +540,15 @@ void InteractionController::handleKey(const KeyIntent& intent) { cancelActive(InteractionCancelReason::Escape); publishOverlay(); + return; + } + + // With the measure tool active and nothing in progress, Escape is + // the keyboard path that drops the completed measurements, so the + // tool is operable without a mouse. + if (m_activeTool == InteractionTool::Measure && clearMeasurements()) + { + publishOverlay(); } return; @@ -513,6 +643,311 @@ void InteractionController::selectTarget(const InteractionTarget& target) publishOverlay(); } +void InteractionController::beginTextSelection(const PointerIntent& intent) +{ + int pageIndex = -1; + QPointF pagePoint; + { + const StageTimer hitTimer(this, TraceStage::HitTest); + hitTestAt(intent.positionPx, &pageIndex, &pagePoint); + } + + m_state.setPointerPosition(intent.positionPx, pageIndex, pageIndex >= 0 ? std::optional(pagePoint) : std::nullopt); + + if (pageIndex < 0 || !m_textSelectionSource) + { + return; + } + + m_textGesturePage = pageIndex; + m_textAnchorPx = intent.positionPx; + m_textAnchorPagePoint = pagePoint; + + m_state.begin(InteractionKind::TextSelect, token()); + applyTextRange(pageIndex, pagePoint, pagePoint); + publishOverlay(); +} + +void InteractionController::beginMeasurement(const PointerIntent& intent) +{ + int pageIndex = -1; + QPointF pagePoint; + { + const StageTimer hitTimer(this, TraceStage::HitTest); + hitTestAt(intent.positionPx, &pageIndex, &pagePoint); + } + + m_state.setPointerPosition(intent.positionPx, pageIndex, pageIndex >= 0 ? std::optional(pagePoint) : std::nullopt); + + qreal pointToMM = 0.0; + if (pageIndex < 0 || !m_pageUnitSource || !m_pageUnitSource->pagePointToMM(pageIndex, &pointToMM)) + { + // No resolvable page scale is no measurement. A guessed 1.0 would be a + // wrong number presented as a fact. + return; + } + + m_measurePageIndex = pageIndex; + m_measureAnchorPx = intent.positionPx; + m_measurePointToMM = pointToMM; + + if (!m_state.begin(InteractionKind::Measure, token())) + { + return; + } + + m_state.setMeasurementDraft(measureBetween(pageIndex, pagePoint, pagePoint, pointToMM), token()); + publishOverlay(); +} + +void InteractionController::applyTextRange(int pageIndex, QPointF startPagePoint, QPointF endPagePoint) +{ + if (!m_textSelectionSource) + { + return; + } + + const TextSelectionResult result = m_textSelectionSource->selectRange(pageIndex, startPagePoint, endPagePoint); + + TextSelection selection; + selection.kind = TextSelectionKind::Text; + selection.pageIndex = pageIndex; + selection.available = result.available; + selection.unavailableReason = result.unavailableReason; + selection.text = result.text; + selection.geometry = result.geometry; + selection.firstLine = result.firstLine; + selection.lastLine = result.lastLine; + + // A drag in progress is overlay-only: the change is announced once, when + // the gesture completes, not on every pointer delta. + m_state.setTextSelection(selection, token()); +} + +bool InteractionController::storeTextSelection(const TextSelection& selection) +{ + if (!m_state.setTextSelection(selection, token())) + { + return false; + } + + Q_EMIT textSelectionChanged(); + publishOverlay(); + return true; +} + +TextCopyResult InteractionController::selectedText() const +{ + TextCopyResult result; + + if (!m_textSelectionSource) + { + result.refusalCode = QStringLiteral("text/unavailable"); + result.refusalReason = tr("Text selection is unavailable."); + return result; + } + + const std::optional& selection = m_state.textSelection(); + if (!selection.has_value() || !selection->isValid()) + { + result.refusalCode = QStringLiteral("text/nothing-selected"); + result.refusalReason = tr("Nothing is selected."); + return result; + } + + if (m_revisions && !m_revisions->isCurrent(selection->fence.revision)) + { + result.refusalCode = QStringLiteral("text/stale-selection"); + result.refusalReason = tr("The document changed after this selection was made."); + return result; + } + + if (!selection->available) + { + result.refusalCode = QStringLiteral("text/no-text-layer"); + result.refusalReason = selection->unavailableReason.isEmpty() + ? tr("This page has no text layer.") + : selection->unavailableReason; + return result; + } + + if (selection->text.isEmpty()) + { + result.refusalCode = QStringLiteral("text/nothing-selected"); + result.refusalReason = tr("The selection contains no text."); + return result; + } + + result.copied = true; + result.text = selection->text; + return result; +} + +bool InteractionController::selectAllText() +{ + const int pageIndex = m_viewport->currentPage(); + if (pageIndex < 0 || !m_textSelectionSource || !fenceIsCurrent()) + { + return false; + } + + const TextSelectionResult result = m_textSelectionSource->selectPage(pageIndex); + + TextSelection selection; + selection.kind = TextSelectionKind::Text; + selection.pageIndex = pageIndex; + selection.available = result.available; + selection.unavailableReason = result.unavailableReason; + selection.text = result.text; + selection.geometry = result.geometry; + selection.firstLine = result.firstLine; + selection.lastLine = result.lastLine; + + return storeTextSelection(selection); +} + +bool InteractionController::selectTableUnderPointer() +{ + if (!m_textSelectionSource || !fenceIsCurrent()) + { + return false; + } + + QPointF pagePoint; + const int pageIndex = m_viewport->pageUnderPoint(m_state.pointerPx(), &pagePoint); + if (pageIndex < 0) + { + return false; + } + + const TextSelectionResult result = m_textSelectionSource->selectBlockAt(pageIndex, pagePoint); + + TextSelection selection; + selection.kind = TextSelectionKind::Block; + selection.pageIndex = pageIndex; + selection.available = result.available; + selection.unavailableReason = result.unavailableReason; + selection.text = result.text; + selection.geometry = result.geometry; + selection.firstLine = result.firstLine; + selection.lastLine = result.lastLine; + + return storeTextSelection(selection); +} + +bool InteractionController::stepTextSelectionByLine(int direction) +{ + if (direction == 0 || !m_textSelectionSource || !fenceIsCurrent()) + { + return false; + } + + const std::optional& current = m_state.textSelection(); + const bool haveSteppableSelection = current.has_value() && current->isValid() && current->available && current->pageIndex >= 0; + + const int pageIndex = haveSteppableSelection ? current->pageIndex : m_viewport->currentPage(); + if (pageIndex < 0) + { + return false; + } + + const int count = m_textSelectionSource->lineCount(pageIndex); + if (count <= 0) + { + return false; + } + + int target = -1; + if (haveSteppableSelection && current->pageIndex == pageIndex) + { + target = direction > 0 + ? (current->lastLine >= 0 ? current->lastLine + 1 : 0) + : (current->firstLine >= 0 ? current->firstLine - 1 : count - 1); + } + else + { + target = direction > 0 ? 0 : count - 1; + } + + if (target < 0 || target >= count) + { + // The selection is already at the end of the page's text; the caller + // reports that rather than wrapping onto another page. + return false; + } + + const TextSelectionResult result = m_textSelectionSource->selectLine(pageIndex, target); + if (!result.available) + { + return false; + } + + TextSelection selection; + selection.kind = TextSelectionKind::Text; + selection.pageIndex = pageIndex; + selection.available = true; + selection.text = result.text; + selection.geometry = result.geometry; + selection.firstLine = result.firstLine >= 0 ? result.firstLine : target; + selection.lastLine = result.lastLine >= 0 ? result.lastLine : target; + + return storeTextSelection(selection); +} + +bool InteractionController::deselectText() +{ + if (!m_state.clearTextSelection()) + { + return false; + } + + Q_EMIT textSelectionChanged(); + publishOverlay(); + return true; +} + +bool InteractionController::clearMeasurements() +{ + if (!m_state.clearMeasurements()) + { + return false; + } + + Q_EMIT measurementsCleared(); + return true; +} + +void InteractionController::handleRevisionChanged() +{ + const bool wasActive = m_state.isActive() || m_state.drag().has_value(); + if (wasActive) + { + cancelActive(InteractionCancelReason::RevisionChanged); + } + + const bool hadSelection = m_state.textSelection().has_value(); + const bool hadMeasurements = m_state.measurementDraft().has_value() || !m_state.measurements().isEmpty(); + if (!m_state.clearRevisionBoundState()) + { + if (wasActive) + { + publishOverlay(); + } + return; + } + + if (hadSelection) + { + Q_EMIT textSelectionChanged(); + } + if (hadMeasurements) + { + Q_EMIT measurementsCleared(); + } + + publishOverlay(); +} + void InteractionController::invalidate() { cancelActive(InteractionCancelReason::DocumentClosed); diff --git a/LoopLibInteraction/sources/interactioncontroller.h b/LoopLibInteraction/sources/interactioncontroller.h index 2a1cb7c25..d9e315432 100644 --- a/LoopLibInteraction/sources/interactioncontroller.h +++ b/LoopLibInteraction/sources/interactioncontroller.h @@ -31,6 +31,8 @@ #include "interactiontrace.h" #include "overlaybuilder.h" #include "overlayframe.h" +#include "pageunitsource.h" +#include "textselectionsource.h" #include "viewportcontroller.h" #include @@ -39,6 +41,17 @@ namespace pdfinteraction { +/// The outcome of a Copy request. `text` is non-empty exactly when `copied` is +/// true; a refusal always names its reason, so "nothing was copied" is never +/// presented as success with an empty clipboard payload. +struct TextCopyResult +{ + bool copied = false; + QString text; + QString refusalCode; + QString refusalReason; +}; + /// Turns host input into transient state, viewport motion and overlay frames. /// /// This class is where the P4-S4 exit condition is enforced. Everything a @@ -112,6 +125,48 @@ class InteractionController final : public QObject void setActiveTool(InteractionTool tool); InteractionTool activeTool() const noexcept { return m_activeTool; } + /// Text extraction for the Text tool and the selection commands (issue + /// #232). Observed, not owned; may be nullptr, which makes every selection + /// request answer "unavailable" rather than inventing text. + void setTextSelectionSource(ITextSelectionSource* source); + ITextSelectionSource* textSelectionSource() const noexcept { return m_textSelectionSource; } + + /// The page's user-space scale for measurement (issue #238). Observed, not + /// owned; when absent the measure tool refuses to begin rather than measure + /// at a guessed scale. + void setPageUnitSource(IPageUnitSource* source); + IPageUnitSource* pageUnitSource() const noexcept { return m_pageUnitSource; } + + /// The selected text, or the typed reason there is none. The host puts the + /// text on the clipboard; the refusal is never an empty string copied as + /// success. + TextCopyResult selectedText() const; + + /// Selects the current page's whole text layer, in reading order. + bool selectAllText(); + + /// Selects the text block under the pointer as one typed selection. What a + /// table is to the text layer is a block, so this is how a table is + /// selected. + bool selectTableUnderPointer(); + + /// Steps the text selection one line in reading order. With no selection, + /// selects the first line (direction > 0) or the last (direction < 0). + bool stepTextSelectionByLine(int direction); + + /// Drops the text selection. Returns true when there was one. + bool deselectText(); + + /// Drops every measurement. Returns true when there was something to drop. + bool clearMeasurements(); + + /// The document revision moved under the interaction state. Cancels the + /// active gesture with RevisionChanged and drops what was derived from the + /// previous revision -- measurements and the text selection -- because + /// carrying either onto new geometry is presenting a fact about a document + /// the operator is no longer looking at. + void handleRevisionChanged(); + void handlePointer(const PointerIntent& intent); void handleWheel(const WheelIntent& intent); void handleKey(const KeyIntent& intent); @@ -157,6 +212,17 @@ class InteractionController final : public QObject /// hysteresis threshold. The owner turns it into one command invocation. void dragCompleted(pdfinteraction::DragSession session); + /// Exactly one per completed measurement, and never for a click below the + /// hysteresis threshold. Presentation state only: nothing here is routed to + /// a command. + void measurementCompleted(pdfinteraction::Measurement measurement); + + /// Every measurement was dropped, by the operator or by a revision change. + void measurementsCleared(); + + /// The text selection was replaced, stepped or cleared. + void textSelectionChanged(); + void interactionCancelled(pdfinteraction::InteractionCancelReason reason); private: @@ -191,6 +257,23 @@ class InteractionController final : public QObject void handlePointerMove(const PointerIntent& intent); void handlePointerRelease(const PointerIntent& intent); + /// A left press in the Text tool: begins the gesture and the draft range. + void beginTextSelection(const PointerIntent& intent); + + /// A left press in the Measure tool: begins the gesture and the draft + /// measurement. Does nothing when the page has no resolvable scale. + void beginMeasurement(const PointerIntent& intent); + + /// Recomputes the draft text selection from the source and stores it + /// without announcing a change: a drag in progress is overlay-only. + void applyTextRange(int pageIndex, QPointF startPagePoint, QPointF endPagePoint); + + /// Stores a text selection for the current fence and announces it. + bool storeTextSelection(const TextSelection& selection); + + /// True when the fence still names the open document revision. + bool fenceIsCurrent() const; + InteractionTarget hitTestAt(QPoint viewportPx, int* pageIndex, QPointF* pagePoint) const; void cancelActive(InteractionCancelReason reason); @@ -201,6 +284,8 @@ class InteractionController final : public QObject HitTestDispatcher* m_hitTest = nullptr; OverlayBuilder* m_overlays = nullptr; InteractionTraceRecorder* m_trace = nullptr; + ITextSelectionSource* m_textSelectionSource = nullptr; + IPageUnitSource* m_pageUnitSource = nullptr; InteractionState m_state; OverlayFrame m_overlay; @@ -219,6 +304,18 @@ class InteractionController final : public QObject /// Pointer position at the last pan step, so a pan scrolls by the delta /// rather than by the absolute position. QPoint m_panAnchorPx; + + /// The text gesture's page and anchor: a selection stays on the page it + /// began on, exactly as a drag does. + int m_textGesturePage = -1; + QPoint m_textAnchorPx; + QPointF m_textAnchorPagePoint; + + /// The measure gesture's page, anchor and scale, read once at the press + /// from the page's own /UserUnit. + int m_measurePageIndex = -1; + QPoint m_measureAnchorPx; + qreal m_measurePointToMM = 0.0; }; } // namespace pdfinteraction diff --git a/LoopLibInteraction/sources/interactionstate.cpp b/LoopLibInteraction/sources/interactionstate.cpp index df94f8e3b..ee6b30d57 100644 --- a/LoopLibInteraction/sources/interactionstate.cpp +++ b/LoopLibInteraction/sources/interactionstate.cpp @@ -43,6 +43,10 @@ const char* getInteractionKindName(InteractionKind kind) return "marquee"; case InteractionKind::Pan: return "pan"; + case InteractionKind::TextSelect: + return "text-select"; + case InteractionKind::Measure: + return "measure"; case InteractionKind::ToolGesture: return "tool-gesture"; } @@ -58,6 +62,10 @@ const char* getInteractionToolName(InteractionTool tool) return "select"; case InteractionTool::Hand: return "hand"; + case InteractionTool::Text: + return "text"; + case InteractionTool::Measure: + return "measure"; } return "unknown"; @@ -73,10 +81,57 @@ std::optional interactionToolFromName(const QString& name) { return InteractionTool::Hand; } + if (name == QLatin1String("text")) + { + return InteractionTool::Text; + } + if (name == QLatin1String("measure")) + { + return InteractionTool::Measure; + } return std::nullopt; } +const char* getTextSelectionKindName(TextSelectionKind kind) +{ + switch (kind) + { + case TextSelectionKind::Text: + return "text"; + case TextSelectionKind::Block: + return "block"; + } + + return "unknown"; +} + +Measurement measureBetween(int pageIndex, QPointF startPagePoint, QPointF endPagePoint, qreal pointToMM) +{ + Measurement measurement; + measurement.pageIndex = pageIndex; + measurement.startPagePoint = startPagePoint; + measurement.endPagePoint = endPagePoint; + measurement.pointToMM = pointToMM; + + const QPointF delta = endPagePoint - startPagePoint; + const qreal scale = pointToMM > 0.0 ? pointToMM : 1.0; + + measurement.distanceMM = std::hypot(delta.x(), delta.y()) * scale; + measurement.widthMM = qAbs(delta.x()) * scale; + measurement.heightMM = qAbs(delta.y()) * scale; + measurement.perimeterMM = 2.0 * (measurement.widthMM + measurement.heightMM); + measurement.areaMM2 = measurement.widthMM * measurement.heightMM; + measurement.angleDegrees = qRadiansToDegrees(std::atan2(delta.y(), delta.x())); + + return measurement; +} + +QString measurementReadout(const Measurement& measurement) +{ + return QStringLiteral("%1 mm").arg(QString::number(measurement.distanceMM, 'f', 1)); +} + const char* getInteractionCancelReasonName(InteractionCancelReason reason) { switch (reason) @@ -266,6 +321,79 @@ void InteractionState::cancel(InteractionCancelReason reason) m_lastCancelReason = reason; } +bool InteractionState::setMeasurementDraft(const Measurement& draft, const RevisionFencedToken& token) +{ + if (!isCurrent(token) || draft.pageIndex < 0 || !std::isfinite(draft.distanceMM)) + { + return false; + } + + m_measurementDraft = draft; + m_measurementDraft->fence = token; + return true; +} + +std::optional InteractionState::completeMeasurement(const RevisionFencedToken& token) +{ + if (!m_measurementDraft.has_value()) + { + return std::nullopt; + } + + if (!isCurrent(token)) + { + // A measurement that lost its revision is dropped, not carried onto new + // geometry. + cancel(InteractionCancelReason::RevisionChanged); + return std::nullopt; + } + + Measurement measurement = *m_measurementDraft; + measurement.fence = token; + clearTransient(); + + m_measurements.push_back(measurement); + return measurement; +} + +bool InteractionState::setTextSelection(const TextSelection& selection, const RevisionFencedToken& token) +{ + if (!token.isValid() || selection.pageIndex < 0) + { + return false; + } + + m_textSelection = selection; + m_textSelection->fence = token; + return true; +} + +bool InteractionState::clearTextSelection() +{ + if (!m_textSelection.has_value()) + { + return false; + } + + m_textSelection.reset(); + return true; +} + +bool InteractionState::clearMeasurements() +{ + const bool hadSomething = m_measurementDraft.has_value() || !m_measurements.isEmpty(); + m_measurementDraft.reset(); + m_measurements.clear(); + return hadSomething; +} + +bool InteractionState::clearRevisionBoundState() +{ + const bool hadSelection = clearTextSelection(); + const bool hadMeasurements = clearMeasurements(); + return hadSelection || hadMeasurements; +} + bool InteractionState::clearHover() { if (!m_hovered.isValid()) @@ -290,6 +418,7 @@ void InteractionState::reset(InteractionCancelReason reason) m_lastCancelReason = reason; m_hovered = InteractionTarget(); m_selected = InteractionTarget(); + clearRevisionBoundState(); m_pointerPagePoint.reset(); m_pointerPageIndex = -1; } @@ -304,6 +433,7 @@ void InteractionState::clearTransient() m_kind = InteractionKind::None; m_token = RevisionFencedToken(); m_drag.reset(); + m_measurementDraft.reset(); m_pointerCapture = false; } diff --git a/LoopLibInteraction/sources/interactionstate.h b/LoopLibInteraction/sources/interactionstate.h index c22733901..8d5fc3942 100644 --- a/LoopLibInteraction/sources/interactionstate.h +++ b/LoopLibInteraction/sources/interactionstate.h @@ -47,6 +47,16 @@ enum class InteractionKind Drag, Marquee, Pan, + + /// A text selection being dragged out (issue #232). It is its own kind + /// rather than a ToolGesture because what it produces is a typed + /// TextSelection, not a command. + TextSelect, + + /// A measurement being dragged out (issue #238). It never becomes a + /// DragSession: a measurement is presentation state, not a document edit. + Measure, + ToolGesture }; @@ -66,7 +76,17 @@ enum class InteractionTool /// The left button is a viewport grip: a left drag pans and never selects, /// retargets or starts a transform. The configured pan button still pans /// in every tool. - Hand + Hand, + + /// The left button drags out a text selection on the page under the pointer + /// (issue #232). What it produces is the typed TextSelection state; it never + /// selects a target, never starts a transform and never reaches the document. + Text, + + /// The left button drags out a measurement in production units (issue #238). + /// A measurement is presentation state fenced to the revision it was taken + /// from; it is never a document mutation and never a DragSession. + Measure }; const char* getInteractionToolName(InteractionTool tool); @@ -136,6 +156,91 @@ struct DragSession RevisionFencedToken fence; }; +/// One measurement on a page, in production units (issue #238). +/// +/// A measurement is derived presentation state: it names page-space points and +/// the millimetres they span after the page's /UserUnit scale, and no method +/// anywhere turns it into a document edit. The rectangle fields are the +/// axis-aligned region the two points span, so a region drag reads as a +/// distance, a width x height, a perimeter and an area at once. +struct Measurement +{ + int pageIndex = -1; + QPointF startPagePoint; + QPointF endPagePoint; + + /// Millimetres per page-space point this measurement was taken with, + /// including the page's /UserUnit. Carried so a readout can be explained + /// and re-derived without another document lookup. + qreal pointToMM = 1.0; + + qreal distanceMM = 0.0; + qreal widthMM = 0.0; + qreal heightMM = 0.0; + qreal perimeterMM = 0.0; + qreal areaMM2 = 0.0; + qreal angleDegrees = 0.0; + + /// The fence the measurement was taken against. A measurement whose fence no + /// longer matches is dropped rather than carried onto new geometry. + RevisionFencedToken fence; + + bool isValid() const { return pageIndex >= 0 && fence.isValid(); } + bool operator==(const Measurement& other) const = default; +}; + +/// Derives a measurement from two page-space points and the page's +/// millimetres-per-point scale. Pure; the caller supplies the scale it read +/// from the page's own /UserUnit. +Measurement measureBetween(int pageIndex, QPointF startPagePoint, QPointF endPagePoint, qreal pointToMM); + +/// The operator-facing readout of a measurement's distance, e.g. "42.3 mm". +/// The layer names the unit so no host invents a second one. +QString measurementReadout(const Measurement& measurement); + +/// What produced a text selection. +enum class TextSelectionKind +{ + /// A text range: a drag between two page points, or a whole page. + Text, + + /// One whole text block, which is what a table or a paragraph is to the + /// text layer (issue #232). + Block +}; + +const char* getTextSelectionKindName(TextSelectionKind kind); + +/// A typed text selection (issue #232). +/// +/// Bound to the revision it was taken from through `fence`; a revision change +/// clears it rather than carrying text onto geometry the operator never saw. +/// `available == false` is a real state -- a page with no text layer -- and it +/// is never an empty string presented as success. +struct TextSelection +{ + TextSelectionKind kind = TextSelectionKind::Text; + int pageIndex = -1; + + /// False when the page has no text layer. `unavailableReason` then says so. + bool available = false; + QString unavailableReason; + + QString text; + QList geometry; + + /// Reading-order line range of the selection, or -1 when the selection does + /// not address lines. Stepping by line moves this range. + int firstLine = -1; + int lastLine = -1; + + RevisionFencedToken fence; + + bool isValid() const { return pageIndex >= 0 && fence.isValid(); } + bool isEmpty() const { return text.isEmpty() && geometry.isEmpty(); } + bool operator==(const TextSelection& other) const = default; +}; + /// A cheap, per-event, revision-fenced snapshot of what the pointer is doing. /// /// The class exists to keep direct manipulation off the document. Hover, @@ -181,6 +286,17 @@ class InteractionState final const InteractionTarget& selected() const noexcept { return m_selected; } const std::optional& drag() const noexcept { return m_drag; } + /// The measurement being dragged out, if any. Never a DragSession. + const std::optional& measurementDraft() const noexcept { return m_measurementDraft; } + + /// Completed measurements, in the order they were taken. Cleared by + /// clearMeasurements() and by a revision change, never carried onto new + /// geometry. + const QList& measurements() const noexcept { return m_measurements; } + + /// The typed text selection, or nothing when no page has one. + const std::optional& textSelection() const noexcept { return m_textSelection; } + /// The target that owns pointer capture, if any. A captured pointer keeps /// delivering to the drag even once it leaves the target's bounds. bool hasPointerCapture() const noexcept { return m_pointerCapture; } @@ -221,6 +337,32 @@ class InteractionState final /// session is what a caller turns into exactly one command invocation. std::optional completeDrag(const RevisionFencedToken& token); + /// Sets the in-progress measurement. Refuses a stale token or an invalid + /// measurement, so a measurement can never be steered onto a revision it + /// was not taken against. + bool setMeasurementDraft(const Measurement& draft, const RevisionFencedToken& token); + + /// Ends the in-progress measurement, stores it, and returns it. Nothing is + /// returned for a stale token or when there is no draft; a stale draft is + /// dropped rather than completed. + std::optional completeMeasurement(const RevisionFencedToken& token); + + /// Stores a text selection. Refuses a stale token or a selection without a + /// page, so a selection always names the page it came from. + bool setTextSelection(const TextSelection& selection, const RevisionFencedToken& token); + + /// Drops the text selection. Returns true when there was one. + bool clearTextSelection(); + + /// Drops every measurement, draft and completed. Returns true when there + /// was something to drop. + bool clearMeasurements(); + + /// Drops the state derived from the document revision: the text selection + /// and every measurement. Returns true when something was dropped. A + /// revision change calls this instead of carrying a derived fact forward. + bool clearRevisionBoundState(); + /// Ends the active interaction. Idempotent; always leaves no preview behind. void cancel(InteractionCancelReason reason); @@ -228,7 +370,8 @@ class InteractionState final /// when something was cleared. bool clearHover(); - /// Drops everything, including the selection. For a document replacement. + /// Drops everything, including the selection, the text selection and every + /// measurement. For a document replacement. void reset(InteractionCancelReason reason); /// Whether `token` still addresses the active interaction. @@ -248,6 +391,9 @@ class InteractionState final InteractionTarget m_hovered; InteractionTarget m_selected; std::optional m_drag; + std::optional m_measurementDraft; + QList m_measurements; + std::optional m_textSelection; bool m_pointerCapture = false; int m_dragThresholdPx = DefaultDragThresholdPx; @@ -258,5 +404,8 @@ class InteractionState final Q_DECLARE_METATYPE(pdfinteraction::DragSession) Q_DECLARE_METATYPE(pdfinteraction::InteractionKind) Q_DECLARE_METATYPE(pdfinteraction::InteractionCancelReason) +Q_DECLARE_METATYPE(pdfinteraction::Measurement) +Q_DECLARE_METATYPE(pdfinteraction::TextSelection) +Q_DECLARE_METATYPE(pdfinteraction::TextSelectionKind) #endif // INTERACTIONSTATE_H diff --git a/LoopLibInteraction/sources/overlaybuilder.cpp b/LoopLibInteraction/sources/overlaybuilder.cpp index 3178f8384..6e4233942 100644 --- a/LoopLibInteraction/sources/overlaybuilder.cpp +++ b/LoopLibInteraction/sources/overlaybuilder.cpp @@ -170,7 +170,14 @@ OverlayFrame OverlayBuilder::build(const InteractionState& state, const Revision // object that already has one, and a host keys retained nodes on the id. The // target keeps its own id so a click routed back from a primitive still names // the real object. - const auto emitPrimitive = [&](const InteractionTarget& target, const QString& id, QRectF pageBounds, OverlayLayer layer, OverlayPrimitiveKind kind, OverlaySeverity severity) -> bool + const auto emitPrimitive = [&](const InteractionTarget& target, + const QString& id, + QRectF pageBounds, + OverlayLayer layer, + OverlayPrimitiveKind kind, + OverlaySeverity severity, + const QString& label = QString(), + const QPolygonF& pagePolygon = QPolygonF()) -> bool { if (frame.primitives.size() >= m_bounds.maxPrimitives) { @@ -191,6 +198,8 @@ OverlayFrame OverlayBuilder::build(const InteractionState& state, const Revision primitive.severity = severity; primitive.pageIndex = target.pageIndex; primitive.pageBounds = pageBounds; + primitive.pagePolygon = pagePolygon; + primitive.label = label; primitive.target = target; primitive.sequence = sequence++; primitive.hovered = state.hovered().isValid() && state.hovered().id == target.id && state.hovered().pageIndex == target.pageIndex; @@ -240,6 +249,48 @@ OverlayFrame OverlayBuilder::build(const InteractionState& state, const Revision emitPrimitive(guide, guide.id, guide.pageBounds, layer, OverlayPrimitiveKind::Rectangle, OverlaySeverity::None); } + // Measurements paint in the Guides band the z-order table already assigns + // them. They are presentation state: a measurement has no command and no + // mutation behind it, and a revision change has already dropped it by the + // time this frame is rebuilt. + if (!suppressExtraGraphics) + { + const auto emitMeasurement = [&](const Measurement& measurement, const QString& id) + { + if (!isVisiblePage(measurement.pageIndex)) + { + return; + } + + InteractionTarget target; + target.kind = InteractionTargetKind::Guide; + target.pageIndex = measurement.pageIndex; + target.id = id; + target.pageBounds = QRectF(measurement.startPagePoint, measurement.endPagePoint).normalized(); + + QPolygonF line; + line << measurement.startPagePoint << measurement.endPagePoint; + + emitPrimitive(target, + id, + target.pageBounds, + OverlayLayer::Guides, + OverlayPrimitiveKind::Measurement, + OverlaySeverity::None, + measurementReadout(measurement), + line); + }; + + if (state.measurementDraft().has_value()) + { + emitMeasurement(*state.measurementDraft(), QStringLiteral("measurement#draft")); + } + for (int index = 0; index < state.measurements().size(); ++index) + { + emitMeasurement(state.measurements().at(index), QStringLiteral("measurement#%1").arg(index)); + } + } + for (const InteractionTarget& finding : m_findings) { if (suppressExtraGraphics || !isVisiblePage(finding.pageIndex) || m_hiddenFindingIds.contains(finding.id)) @@ -287,6 +338,27 @@ OverlayFrame OverlayBuilder::build(const InteractionState& state, const Revision emitPrimitive(selected, selected.id + QStringLiteral("#selection"), selected.pageBounds, OverlayLayer::Selection, OverlayPrimitiveKind::Rectangle, OverlaySeverity::None); } + // A text selection paints in the Selection band, one rectangle per line + // span, so the operator sees which lines are selected. It carries no + // target: there is no text target kind, and clicking selected text is a + // new selection, not a routed command. + const std::optional& textSelection = state.textSelection(); + if (!suppressExtraGraphics && textSelection.has_value() && textSelection->available && isVisiblePage(textSelection->pageIndex)) + { + for (int index = 0; index < textSelection->geometry.size(); ++index) + { + InteractionTarget target; + target.pageIndex = textSelection->pageIndex; + + emitPrimitive(target, + QStringLiteral("text-selection#%1").arg(index), + textSelection->geometry.at(index), + OverlayLayer::Selection, + OverlayPrimitiveKind::Rectangle, + OverlaySeverity::None); + } + } + for (const InteractionTarget& handle : m_handles) { if (suppressExtraGraphics || !isVisiblePage(handle.pageIndex)) diff --git a/LoopLibInteraction/sources/overlayframe.cpp b/LoopLibInteraction/sources/overlayframe.cpp index 45dd6505c..64354695d 100644 --- a/LoopLibInteraction/sources/overlayframe.cpp +++ b/LoopLibInteraction/sources/overlayframe.cpp @@ -62,6 +62,8 @@ const char* getOverlayPrimitiveKindName(OverlayPrimitiveKind kind) return "marker"; case OverlayPrimitiveKind::Handle: return "handle"; + case OverlayPrimitiveKind::Measurement: + return "measurement"; } return "unknown"; diff --git a/LoopLibInteraction/sources/overlayframe.h b/LoopLibInteraction/sources/overlayframe.h index 15d1df16e..2d18695a2 100644 --- a/LoopLibInteraction/sources/overlayframe.h +++ b/LoopLibInteraction/sources/overlayframe.h @@ -67,7 +67,12 @@ enum class OverlayPrimitiveKind Rectangle, Polyline, Marker, - Handle + Handle, + + /// A measurement: a dimension line between two page-space points, with the + /// readout in `label`. It paints in the Guides band the z-order table + /// already assigns to measurements. + Measurement }; const char* getOverlayPrimitiveKindName(OverlayPrimitiveKind kind); @@ -104,6 +109,11 @@ struct OverlayPrimitive QRectF pageBounds; QPolygonF pagePolygon; + /// The readout of a measurement primitive, e.g. "42.3 mm". Empty for every + /// other kind; a host draws it near the line and a screen reader announces + /// it without deriving either. + QString label; + /// The thing this primitive represents, so a host can route a click back /// without a second lookup table. InteractionTarget target; diff --git a/LoopLibInteraction/sources/pageunitsource.h b/LoopLibInteraction/sources/pageunitsource.h new file mode 100644 index 000000000..c0d8a1bb6 --- /dev/null +++ b/LoopLibInteraction/sources/pageunitsource.h @@ -0,0 +1,52 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#ifndef PAGEUNITSOURCE_H +#define PAGEUNITSOURCE_H + +#include "interactionglobal.h" + +#include + +namespace pdfinteraction +{ + +/// The scale of a page's user space. +/// +/// A measurement is only a production measurement once the page's own /UserUnit +/// is applied: without it a 100-unit line on a /UserUnit 2 page reads half its +/// real length. The interaction layer asks for the scale here instead of +/// reading a document, and a page whose scale cannot be resolved is refused +/// rather than measured at a guessed 1.0. +class IPageUnitSource +{ +public: + virtual ~IPageUnitSource() = default; + + /// Millimetres one page-space point spans, including the page's /UserUnit. + /// Returns false when the page cannot be resolved. + virtual bool pagePointToMM(int pageIndex, qreal* factor) const = 0; +}; + +} // namespace pdfinteraction + +#endif // PAGEUNITSOURCE_H diff --git a/LoopLibInteraction/sources/pdfdocumentimageextractor.cpp b/LoopLibInteraction/sources/pdfdocumentimageextractor.cpp new file mode 100644 index 000000000..b884dff04 --- /dev/null +++ b/LoopLibInteraction/sources/pdfdocumentimageextractor.cpp @@ -0,0 +1,152 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "pdfdocumentimageextractor.h" + +#include "pdfcatalog.h" +#include "pdfdocumentsession.h" +#include "pdfmeshqualitysettings.h" +#include "pdfpage.h" +#include "pdfpagecontentprocessor.h" + +namespace pdfinteraction +{ + +namespace +{ + +/// Walks one page's content stream and keeps every painted image, in paint +/// order. The walk is Core's PDFPageContentProcessor -- the same one PdfTool's +/// fetch-images drives -- so both surfaces extract the same images the same way. +class PageImageProcessor final : public pdf::PDFPageContentProcessor +{ + using BaseClass = pdf::PDFPageContentProcessor; + +public: + PageImageProcessor(const pdf::PDFPage* page, + const pdf::PDFDocument* document, + const pdf::PDFFontCache* fontCache, + const pdf::PDFCMS* cms, + const pdf::PDFOptionalContentActivity* optionalContentActivity, + const pdf::PDFMeshQualitySettings& meshQualitySettings, + int pageIndex, + QList* images) : + BaseClass(page, document, fontCache, cms, optionalContentActivity, QTransform(), meshQualitySettings), + m_pageIndex(pageIndex), + m_images(images) + { + } + +protected: + bool isContentSuppressedByOC(pdf::PDFObjectReference ocgOrOcmd) override + { + Q_UNUSED(ocgOrOcmd); + return false; + } + + bool isContentKindSuppressed(ContentKind kind) const override + { + switch (kind) + { + case ContentKind::Shapes: + case ContentKind::Text: + case ContentKind::Shading: + return true; + + case ContentKind::Tiling: + case ContentKind::Images: + return false; // Tiling can have images + + default: + { + Q_ASSERT(false); + break; + } + } + + return false; + } + + void performImagePainting(const QImage& image) override + { + PageImage pageImage; + pageImage.pageIndex = m_pageIndex; + pageImage.order = m_images->size(); + pageImage.image = image; + m_images->push_back(pageImage); + } + +private: + int m_pageIndex = -1; + QList* m_images = nullptr; +}; + +} // namespace + +PDFDocumentImageExtractor::PDFDocumentImageExtractor(pdf::PDFDocumentContext* context) : + m_context(context) +{ +} + +QList PDFDocumentImageExtractor::extractPage(int pageIndex, QString* typedError) const +{ + const auto refuse = [typedError](const QString& reason) + { + if (typedError) + { + *typedError = reason; + } + return QList(); + }; + + pdf::PDFDocumentContext* context = m_context; + if (!context) + { + return refuse(tr("No document is open.")); + } + + pdf::PDFDocumentSession* session = context->getSession(); + const pdf::PDFDocument* document = context->getDocument(); + if (!session || !document) + { + return refuse(tr("No document is open.")); + } + + const pdf::PDFCatalog* catalog = document->getCatalog(); + const bool pageInRange = catalog && pageIndex >= 0 && + static_cast(pageIndex) < catalog->getPageCount(); + const pdf::PDFPage* page = pageInRange ? catalog->getPage(static_cast(pageIndex)) : nullptr; + if (!page) + { + return refuse(tr("The page does not exist.")); + } + + const pdf::PDFMeshQualitySettings meshQuality; + QList images; + PageImageProcessor processor(page, document, session->getFontCache(), session->getCMS(), + session->getOptionalContentActivity(), meshQuality, pageIndex, &images); + processor.processContents(); + + return images; +} + +} // namespace pdfinteraction diff --git a/LoopLibInteraction/sources/pdfdocumentimageextractor.h b/LoopLibInteraction/sources/pdfdocumentimageextractor.h new file mode 100644 index 000000000..7711f0024 --- /dev/null +++ b/LoopLibInteraction/sources/pdfdocumentimageextractor.h @@ -0,0 +1,74 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#ifndef PDFDOCUMENTIMAGEEXTRACTOR_H +#define PDFDOCUMENTIMAGEEXTRACTOR_H + +#include "interactionglobal.h" + +#include "pdfdocumentcontext.h" + +#include +#include +#include +#include + +namespace pdfinteraction +{ + +/// One image a page's content stream paints. +struct PageImage +{ + int pageIndex = -1; + + /// Paint order within the page, from zero. The same ordinal PdfTool's + /// fetch-images reports, so the two surfaces name an image identically. + int order = 0; + + QImage image; +}; + +/// Extracts the images a page paints, in paint order. +/// +/// The extraction is Core's: the same PDFPageContentProcessor walk PdfTool's +/// `fetch-images` uses, so a Quick extraction and a CLI extraction of the same +/// page produce the same images in the same order. The context is observed +/// through a QPointer, so a destroyed context degrades to "no document" rather +/// than a dangling read. +class PDFDocumentImageExtractor final +{ + Q_DECLARE_TR_FUNCTIONS(pdfinteraction::PDFDocumentImageExtractor) + +public: + explicit PDFDocumentImageExtractor(pdf::PDFDocumentContext* context); + + /// Images `pageIndex` paints, in paint order. Returns an empty list with + /// `typedError` set when the page cannot be read. + QList extractPage(int pageIndex, QString* typedError) const; + +private: + QPointer m_context; +}; + +} // namespace pdfinteraction + +#endif // PDFDOCUMENTIMAGEEXTRACTOR_H diff --git a/LoopLibInteraction/sources/pdfdocumenttextselectionsource.cpp b/LoopLibInteraction/sources/pdfdocumenttextselectionsource.cpp new file mode 100644 index 000000000..ab9b3c2e1 --- /dev/null +++ b/LoopLibInteraction/sources/pdfdocumenttextselectionsource.cpp @@ -0,0 +1,364 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#include "pdfdocumenttextselectionsource.h" + +#include "pdfcatalog.h" +#include "pdfdocumentsession.h" +#include "pdfexception.h" +#include "pdfpage.h" +#include "pdfprocessingbudget.h" +#include "pdfrenderer.h" +#include "pdftextlayoutgenerator.h" + +namespace pdfinteraction +{ + +namespace +{ + +/// A selection's own colour is presentation state the host owns; Core is asked +/// for geometry and text, never for a palette. +const QColor SelectionScratchColor = QColor(0, 0, 0, 0); + +/// Page-space rectangles of one selection item, one per line span. A selection +/// item stays inside one block -- PDFTextLayout::createTextSelection adds one +/// item per block -- so the line walk cannot cross a block boundary. +void appendItemGeometry(const pdf::PDFTextLayout& layout, + const pdf::PDFTextSelectionColoredItem& item, + QList* geometry) +{ + const pdf::PDFTextBlocks& blocks = layout.getTextBlocks(); + if (item.start.blockIndex >= blocks.size()) + { + return; + } + + const pdf::PDFTextLines& lines = blocks[item.start.blockIndex].getLines(); + const size_t firstLine = item.start.lineIndex; + const size_t lastLine = item.end.blockIndex == item.start.blockIndex ? item.end.lineIndex : firstLine; + + for (size_t lineIndex = firstLine; lineIndex <= lastLine && lineIndex < lines.size(); ++lineIndex) + { + const pdf::TextCharacters& characters = lines[lineIndex].getCharacters(); + if (characters.empty()) + { + continue; + } + + const size_t firstCharacter = lineIndex == firstLine ? item.start.characterIndex : 0; + const size_t lastCharacter = + item.end.blockIndex == item.start.blockIndex && lineIndex == lastLine + ? qMin(item.end.characterIndex, characters.size() - 1) + : characters.size() - 1; + + QRectF lineBounds; + bool any = false; + for (size_t characterIndex = firstCharacter; characterIndex <= lastCharacter && characterIndex < characters.size(); ++characterIndex) + { + const QRectF bounds = characters[characterIndex].boundingBox.boundingRect(); + if (bounds.isEmpty()) + { + continue; + } + + lineBounds = any ? lineBounds.united(bounds) : bounds; + any = true; + } + + if (any) + { + geometry->push_back(lineBounds); + } + } +} + +} // namespace + +PDFDocumentTextSelectionSource::PDFDocumentTextSelectionSource(pdf::PDFDocumentContext* context) : + m_context(context) +{ +} + +void PDFDocumentTextSelectionSource::dropLayoutCacheIfStale() const +{ + const pdf::PDFRevisionIdentity revision = m_context ? m_context->getRevision() : pdf::PDFRevisionIdentity(); + if (revision != m_layoutRevision) + { + m_layouts.clear(); + m_layoutRevision = revision; + } +} + +pdf::PDFTextLayout* PDFDocumentTextSelectionSource::mutableTextLayout(int pageIndex, QString* unavailableReason) const +{ + const auto unavailable = [unavailableReason](const QString& reason) -> pdf::PDFTextLayout* + { + if (unavailableReason) + { + *unavailableReason = reason; + } + return nullptr; + }; + + pdf::PDFDocumentContext* context = m_context; + if (!context) + { + return unavailable(tr("No document is open.")); + } + + pdf::PDFDocumentSession* session = context->getSession(); + const pdf::PDFDocument* document = context->getDocument(); + if (!session || !document) + { + return unavailable(tr("No document is open.")); + } + + dropLayoutCacheIfStale(); + + const auto cached = m_layouts.find(pageIndex); + if (cached != m_layouts.end()) + { + return &cached.value(); + } + + const pdf::PDFCatalog* catalog = document->getCatalog(); + const pdf::PDFPage* page = catalog && pageIndex >= 0 ? catalog->getPage(static_cast(pageIndex)) : nullptr; + if (!page) + { + return unavailable(tr("The page does not exist.")); + } + + const pdf::PDFRenderer::Features features = pdf::PDFRenderer::IgnoreOptionalContent; + const pdf::PDFMeshQualitySettings meshQuality; + pdf::PDFProcessingBudget budget(session->getProcessingLimits()); + try + { + pdf::PDFTextLayoutGenerator generator(features, page, document, + session->getFontCache(), session->getCMS(), + session->getOptionalContentActivity(), QTransform(), meshQuality, + &budget); + generator.processContents(); + m_layouts.insert(pageIndex, generator.createTextLayout()); + } + catch (const pdf::PDFBudgetExceededException& exception) + { + return unavailable(exception.getMessage()); + } + + pdf::PDFTextLayout& layout = m_layouts[pageIndex]; + if (layout.getTextBlocks().empty()) + { + return unavailable(tr("This page has no text layer.")); + } + + return &layout; +} + +int PDFDocumentTextSelectionSource::lineOrdinal(const pdf::PDFTextLayout& layout, size_t blockIndex, size_t lineIndex) const +{ + int ordinal = 0; + const pdf::PDFTextBlocks& blocks = layout.getTextBlocks(); + for (size_t block = 0; block < blocks.size(); ++block) + { + const pdf::PDFTextLines& lines = blocks[block].getLines(); + for (size_t line = 0; line < lines.size(); ++line, ++ordinal) + { + if (block == blockIndex && line == lineIndex) + { + return ordinal; + } + } + } + + return -1; +} + +TextSelectionResult PDFDocumentTextSelectionSource::resultFrom(const pdf::PDFTextLayout& layout, + const pdf::PDFTextSelection& selection, + int pageIndex) const +{ + TextSelectionResult result; + result.available = true; + result.text = layout.getTextFromSelection(selection, pageIndex); + + for (auto item = selection.begin(pageIndex); item != selection.end(pageIndex); ++item) + { + if (item->start.pageIndex != pageIndex) + { + continue; + } + + appendItemGeometry(layout, *item, &result.geometry); + + const int firstOrdinal = lineOrdinal(layout, item->start.blockIndex, item->start.lineIndex); + const int lastOrdinal = lineOrdinal(layout, item->end.blockIndex, item->end.lineIndex); + if (firstOrdinal >= 0) + { + result.firstLine = result.firstLine < 0 ? firstOrdinal : qMin(result.firstLine, firstOrdinal); + } + if (lastOrdinal >= 0) + { + result.lastLine = qMax(result.lastLine, lastOrdinal); + } + } + + return result; +} + +bool PDFDocumentTextSelectionSource::hasTextLayer(int pageIndex) const +{ + return mutableTextLayout(pageIndex, nullptr) != nullptr; +} + +TextSelectionResult PDFDocumentTextSelectionSource::selectRange(int pageIndex, QPointF startPagePoint, QPointF endPagePoint) +{ + QString unavailableReason; + pdf::PDFTextLayout* layout = mutableTextLayout(pageIndex, &unavailableReason); + if (!layout) + { + TextSelectionResult result; + result.unavailableReason = unavailableReason; + return result; + } + + const pdf::PDFTextSelection selection = + layout->createTextSelection(pageIndex, startPagePoint, endPagePoint, SelectionScratchColor, false); + return resultFrom(*layout, selection, pageIndex); +} + +TextSelectionResult PDFDocumentTextSelectionSource::selectPage(int pageIndex) +{ + QString unavailableReason; + pdf::PDFTextLayout* layout = mutableTextLayout(pageIndex, &unavailableReason); + if (!layout) + { + TextSelectionResult result; + result.unavailableReason = unavailableReason; + return result; + } + + // Every block, in the layout's own reading order, as one selection. A + // range selection over the media box would instead depend on where the box + // corners fall relative to the text, which is not the same question. + pdf::PDFTextSelection selection; + const pdf::PDFTextBlocks& blocks = layout->getTextBlocks(); + for (size_t blockIndex = 0; blockIndex < blocks.size(); ++blockIndex) + { + const pdf::PDFTextSelection blockSelection = layout->selectBlock(blockIndex, pageIndex, SelectionScratchColor); + pdf::PDFTextSelectionItems items; + for (auto item = blockSelection.begin(pageIndex); item != blockSelection.end(pageIndex); ++item) + { + items.push_back(pdf::PDFTextSelectionItem(item->start, item->end)); + } + selection.addItems(items, SelectionScratchColor); + } + selection.build(); + + return resultFrom(*layout, selection, pageIndex); +} + +TextSelectionResult PDFDocumentTextSelectionSource::selectBlockAt(int pageIndex, QPointF pagePoint) +{ + QString unavailableReason; + pdf::PDFTextLayout* layout = mutableTextLayout(pageIndex, &unavailableReason); + if (!layout) + { + TextSelectionResult result; + result.unavailableReason = unavailableReason; + return result; + } + + const pdf::PDFTextBlocks& blocks = layout->getTextBlocks(); + for (size_t blockIndex = 0; blockIndex < blocks.size(); ++blockIndex) + { + if (!blocks[blockIndex].getBoundingBox().contains(pagePoint)) + { + continue; + } + + // The first block containing the point wins; a block may be a table or + // a paragraph, and the text layer is what tells them apart. + const pdf::PDFTextSelection selection = layout->selectBlock(blockIndex, pageIndex, SelectionScratchColor); + return resultFrom(*layout, selection, pageIndex); + } + + // No block under the point: an available page with nothing selected. + TextSelectionResult result; + result.available = true; + return result; +} + +int PDFDocumentTextSelectionSource::lineCount(int pageIndex) const +{ + QString unavailableReason; + const pdf::PDFTextLayout* layout = mutableTextLayout(pageIndex, &unavailableReason); + if (!layout) + { + return -1; + } + + int count = 0; + for (const pdf::PDFTextBlock& block : layout->getTextBlocks()) + { + count += static_cast(block.getLines().size()); + } + return count; +} + +TextSelectionResult PDFDocumentTextSelectionSource::selectLine(int pageIndex, int lineIndex) +{ + QString unavailableReason; + pdf::PDFTextLayout* layout = mutableTextLayout(pageIndex, &unavailableReason); + if (!layout) + { + TextSelectionResult result; + result.unavailableReason = unavailableReason; + return result; + } + + int ordinal = 0; + const pdf::PDFTextBlocks& blocks = layout->getTextBlocks(); + for (size_t blockIndex = 0; blockIndex < blocks.size(); ++blockIndex) + { + const pdf::PDFTextLines& lines = blocks[blockIndex].getLines(); + for (size_t line = 0; line < lines.size(); ++line, ++ordinal) + { + if (ordinal != lineIndex) + { + continue; + } + + const pdf::PDFTextSelection selection = layout->selectLineInBlock(blockIndex, line, pageIndex, SelectionScratchColor); + TextSelectionResult result = resultFrom(*layout, selection, pageIndex); + result.firstLine = lineIndex; + result.lastLine = lineIndex; + return result; + } + } + + TextSelectionResult result; + result.available = true; + return result; +} + +} // namespace pdfinteraction diff --git a/LoopLibInteraction/sources/pdfdocumenttextselectionsource.h b/LoopLibInteraction/sources/pdfdocumenttextselectionsource.h new file mode 100644 index 000000000..8810e23d3 --- /dev/null +++ b/LoopLibInteraction/sources/pdfdocumenttextselectionsource.h @@ -0,0 +1,84 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#ifndef PDFDOCUMENTTEXTSELECTIONSOURCE_H +#define PDFDOCUMENTTEXTSELECTIONSOURCE_H + +#include "interactionglobal.h" +#include "textselectionsource.h" + +#include "pdfdocumentcontext.h" +#include "pdftextlayout.h" + +#include +#include + +namespace pdfinteraction +{ + +/// Answers text selection questions from the document the context holds. +/// +/// Extraction is Core's: the same PDFTextLayoutGenerator and text-flow +/// machinery pdf::searchDocumentText uses, so a copied selection is the text +/// layer the document search already agreed on, including the docstrum reading +/// order. Layouts are cached per page and dropped when the context's revision +/// moves, because a layout describes one revision and nothing else. +/// +/// The context is observed through a QPointer, so a destroyed context degrades +/// to "no document" rather than a dangling read -- the same rule +/// PDFDocumentContextSource and PDFDocumentPageGeometrySource follow. +class PDFDocumentTextSelectionSource final : public ITextSelectionSource +{ + Q_DECLARE_TR_FUNCTIONS(pdfinteraction::PDFDocumentTextSelectionSource) + +public: + explicit PDFDocumentTextSelectionSource(pdf::PDFDocumentContext* context); + + bool hasTextLayer(int pageIndex) const override; + TextSelectionResult selectRange(int pageIndex, QPointF startPagePoint, QPointF endPagePoint) override; + TextSelectionResult selectPage(int pageIndex) override; + TextSelectionResult selectBlockAt(int pageIndex, QPointF pagePoint) override; + int lineCount(int pageIndex) const override; + TextSelectionResult selectLine(int pageIndex, int lineIndex) override; + +private: + /// The page's layout, built on first use and cached for the current + /// revision. Returns nullptr with `unavailableReason` set when the page has + /// no text layer or the document cannot be read. + pdf::PDFTextLayout* mutableTextLayout(int pageIndex, QString* unavailableReason) const; + + /// One result from one selection, with page-space geometry per line span. + TextSelectionResult resultFrom(const pdf::PDFTextLayout& layout, const pdf::PDFTextSelection& selection, int pageIndex) const; + + /// Reading-order ordinal of (blockIndex, lineIndex), or -1. + int lineOrdinal(const pdf::PDFTextLayout& layout, size_t blockIndex, size_t lineIndex) const; + + void dropLayoutCacheIfStale() const; + + QPointer m_context; + mutable pdf::PDFRevisionIdentity m_layoutRevision; + mutable QHash m_layouts; +}; + +} // namespace pdfinteraction + +#endif // PDFDOCUMENTTEXTSELECTIONSOURCE_H diff --git a/LoopLibInteraction/sources/textselectionsource.h b/LoopLibInteraction/sources/textselectionsource.h new file mode 100644 index 000000000..d98092b3d --- /dev/null +++ b/LoopLibInteraction/sources/textselectionsource.h @@ -0,0 +1,88 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#ifndef TEXTSELECTIONSOURCE_H +#define TEXTSELECTIONSOURCE_H + +#include "interactionglobal.h" + +#include +#include +#include +#include + +namespace pdfinteraction +{ + +/// What one selection request produced. `available == false` means the page has +/// no text layer to select from; it is a reported state, never an empty string +/// presented as success. +struct TextSelectionResult +{ + bool available = false; + QString unavailableReason; + QString text; + + /// Page-space rectangles of the selected characters, one per line span. + QList geometry; + + /// Reading-order line range of the result, or -1 when it does not address + /// lines. + int firstLine = -1; + int lastLine = -1; +}; + +/// Text extraction for the interaction layer (issue #232). +/// +/// The seam exists because a text selection needs the document's text layer and +/// the interaction layer holds no document. An implementation answers in page +/// space and in Core's own reading order; the interaction layer re-implements +/// no extraction and invents no order of its own. +class ITextSelectionSource +{ +public: + virtual ~ITextSelectionSource() = default; + + /// Whether the page has a text layer at all. + virtual bool hasTextLayer(int pageIndex) const = 0; + + /// Text between two page-space points, in reading order. + virtual TextSelectionResult selectRange(int pageIndex, QPointF startPagePoint, QPointF endPagePoint) = 0; + + /// The page's whole text layer, in reading order. + virtual TextSelectionResult selectPage(int pageIndex) = 0; + + /// The one text block containing a page-space point -- a table or a + /// paragraph to the text layer. + virtual TextSelectionResult selectBlockAt(int pageIndex, QPointF pagePoint) = 0; + + /// Lines the page's text layer has in reading order, or -1 when the page + /// has no text layer. + virtual int lineCount(int pageIndex) const = 0; + + /// One line by reading-order ordinal. + virtual TextSelectionResult selectLine(int pageIndex, int lineIndex) = 0; +}; + +} // namespace pdfinteraction + +#endif // TEXTSELECTIONSOURCE_H diff --git a/LoopLibInteraction/sources/viewportcontroller.cpp b/LoopLibInteraction/sources/viewportcontroller.cpp index 523cf4daa..c951244ef 100644 --- a/LoopLibInteraction/sources/viewportcontroller.cpp +++ b/LoopLibInteraction/sources/viewportcontroller.cpp @@ -28,6 +28,7 @@ #include #include +#include namespace pdfinteraction { @@ -126,6 +127,33 @@ QTransform PDFDocumentPageGeometrySource::pagePointToDeviceMatrix(int pageIndex, return pdf::PDFRenderer::createPagePointToDevicePointMatrix(page, deviceRect, extraRotation); } +bool PDFDocumentPageGeometrySource::pagePointToMM(int pageIndex, qreal* factor) const +{ + const pdf::PDFDocument* document = this->document(); + if (!document || !factor || pageIndex < 0 || pageIndex >= pageCount()) + { + return false; + } + + const pdf::PDFPage* page = document->getCatalog()->getPage(static_cast(pageIndex)); + if (!page) + { + return false; + } + + // /UserUnit is a multiplier of 1/72 inch (ISO 32000-2). A missing or + // nonsensical value is refused rather than coerced to 1.0, because a + // measurement at a guessed scale is a wrong number presented as a fact. + const pdf::PDFReal userUnit = page->getUserUnit(); + if (!(userUnit > 0.0) || !std::isfinite(userUnit)) + { + return false; + } + + *factor = pdf::PDF_POINT_TO_MM * userUnit; + return true; +} + ViewportController::ViewportController(QObject* parent) : QObject(parent) { diff --git a/LoopLibInteraction/sources/viewportcontroller.h b/LoopLibInteraction/sources/viewportcontroller.h index e30c26116..f11d0db5a 100644 --- a/LoopLibInteraction/sources/viewportcontroller.h +++ b/LoopLibInteraction/sources/viewportcontroller.h @@ -25,6 +25,7 @@ #include "interactionglobal.h" +#include "pageunitsource.h" #include "pdfdocumentcontext.h" #include "pdfpage.h" @@ -89,7 +90,11 @@ class IPageGeometrySource /// The context is observed through a QPointer, so a destroyed context degrades to /// an empty document rather than a dangling read -- the same rule /// PDFDocumentContextSource follows. -class PDFDocumentPageGeometrySource final : public IPageGeometrySource +/// +/// It also answers the page's user-space scale (IPageUnitSource), because that +/// is the same page-facts question: the viewport's caller reads the page's +/// /UserUnit for measurement without a second document-backed adapter. +class PDFDocumentPageGeometrySource final : public IPageGeometrySource, public IPageUnitSource { public: explicit PDFDocumentPageGeometrySource(pdf::PDFDocumentContext* context); @@ -98,6 +103,8 @@ class PDFDocumentPageGeometrySource final : public IPageGeometrySource QSizeF pageSizeMM(int pageIndex, pdf::PageRotation extraRotation) const override; QTransform pagePointToDeviceMatrix(int pageIndex, const QRectF& deviceRect, pdf::PageRotation extraRotation) const override; + bool pagePointToMM(int pageIndex, qreal* factor) const override; + private: const pdf::PDFDocument* document() const; diff --git a/UnitTests/tst_documentviewsessiontest.cpp b/UnitTests/tst_documentviewsessiontest.cpp index 6b0c427e9..f15d3c343 100644 --- a/UnitTests/tst_documentviewsessiontest.cpp +++ b/UnitTests/tst_documentviewsessiontest.cpp @@ -7,11 +7,155 @@ #include "pdfdocumentsession.h" #include "pdfpagecachebudget.h" +#include "interactioncontroller.h" #include "overlaybuilder.h" +#include "pdfdocumentimageextractor.h" +#include "pdfexception.h" #include "pdfrenderer.h" #include +namespace +{ + +/// A page whose /UserUnit is 2 and whose own /Rotate is 90. Both are the +/// fixture facts issue #238 asks a measurement to respect; the page is built by +/// hand so the test needs no golden file. +pdf::PDFDocument makeRotatedUserUnitDocument() +{ + pdf::PDFDocumentBuilder builder; + const pdf::PDFObjectReference page = builder.appendPage(QRectF(0, 0, 400, 200)); + builder.setPageRotation(page, pdf::PageRotation::Rotate90); + builder.setPageUserUnit(page, 2.0); + return builder.build(); +} + +/// A page carrying one 2x2 RGB image XObject and no text at all: the fixture +/// for "a page with no text layer" and for image extraction. +pdf::PDFDocument makeImageOnlyDocument() +{ + pdf::PDFDocumentBuilder builder; + const pdf::PDFObjectReference page = builder.appendPage(QRectF(0, 0, 200, 200)); + + QByteArray imageData; + for (int pixel = 0; pixel < 4; ++pixel) + { + imageData.append('\xff'); + imageData.append('\0'); + imageData.append('\0'); + } + + pdf::PDFDictionary imageDictionary; + imageDictionary.addEntry(pdf::PDFInplaceOrMemoryString("Type"), pdf::PDFObject::createName("XObject")); + imageDictionary.addEntry(pdf::PDFInplaceOrMemoryString("Subtype"), pdf::PDFObject::createName("Image")); + imageDictionary.addEntry(pdf::PDFInplaceOrMemoryString("Width"), pdf::PDFObject::createInteger(2)); + imageDictionary.addEntry(pdf::PDFInplaceOrMemoryString("Height"), pdf::PDFObject::createInteger(2)); + imageDictionary.addEntry(pdf::PDFInplaceOrMemoryString("ColorSpace"), pdf::PDFObject::createName("DeviceRGB")); + imageDictionary.addEntry(pdf::PDFInplaceOrMemoryString("BitsPerComponent"), pdf::PDFObject::createInteger(8)); + imageDictionary.addEntry(pdf::PDFInplaceOrMemoryString(pdf::PDF_STREAM_DICT_LENGTH), pdf::PDFObject::createInteger(imageData.size())); + pdf::PDFStream imageStream(std::move(imageDictionary), std::move(imageData)); + const pdf::PDFObjectReference imageReference = + builder.addObject(pdf::PDFObject::createStream(std::make_shared(imageStream))); + + const QByteArray content = "q 40 0 0 40 80 80 cm /Im0 Do Q"; + + pdf::PDFDictionary contentDictionary; + contentDictionary.addEntry(pdf::PDFInplaceOrMemoryString(pdf::PDF_STREAM_DICT_LENGTH), + pdf::PDFObject::createInteger(content.size())); + pdf::PDFStream contentStream(std::move(contentDictionary), QByteArray(content)); + const pdf::PDFObjectReference contentReference = + builder.addObject(pdf::PDFObject::createStream(std::make_shared(contentStream))); + + pdf::PDFDictionary xObjects; + xObjects.addEntry(pdf::PDFInplaceOrMemoryString("Im0"), pdf::PDFObject::createReference(imageReference)); + pdf::PDFDictionary resources; + resources.addEntry(pdf::PDFInplaceOrMemoryString("XObject"), + pdf::PDFObject::createDictionary(std::make_shared(std::move(xObjects)))); + + pdf::PDFDictionary pageUpdate; + pageUpdate.addEntry(pdf::PDFInplaceOrMemoryString("Resources"), + pdf::PDFObject::createDictionary(std::make_shared(std::move(resources)))); + pageUpdate.addEntry(pdf::PDFInplaceOrMemoryString("Contents"), pdf::PDFObject::createReference(contentReference)); + builder.mergeTo(page, pdf::PDFObject::createDictionary(std::make_shared(std::move(pageUpdate)))); + + return builder.build(); +} + +/// A two-column text page with no painter and no GUI: four text-showing +/// operators in a hand-built content stream. The columns are far apart, so a +/// row-major order would interleave them and a reading order would not. +pdf::PDFDocument makeTwoColumnTextDocument() +{ + pdf::PDFDocumentBuilder builder; + const pdf::PDFObjectReference page = builder.appendPage(QRectF(0, 0, 400, 300)); + + pdf::PDFDictionary fontDictionary; + fontDictionary.addEntry(pdf::PDFInplaceOrMemoryString("Type"), pdf::PDFObject::createName("Font")); + fontDictionary.addEntry(pdf::PDFInplaceOrMemoryString("Subtype"), pdf::PDFObject::createName("Type1")); + fontDictionary.addEntry(pdf::PDFInplaceOrMemoryString("BaseFont"), pdf::PDFObject::createName("Helvetica")); + const pdf::PDFObjectReference font = + builder.addObject(pdf::PDFObject::createDictionary(std::make_shared(std::move(fontDictionary)))); + + QByteArray content; + const auto appendLine = [&content](const char* text, int x, int y) + { + content += "BT /F1 12 Tf 1 0 0 1 " + QByteArray::number(x) + " " + QByteArray::number(y) + " Tm (" + text + ") Tj ET\n"; + }; + appendLine("LEFT ONE", 40, 240); + appendLine("LEFT TWO", 40, 220); + appendLine("RIGHT ONE", 240, 240); + appendLine("RIGHT TWO", 240, 220); + + pdf::PDFDictionary contentDictionary; + contentDictionary.addEntry(pdf::PDFInplaceOrMemoryString(pdf::PDF_STREAM_DICT_LENGTH), + pdf::PDFObject::createInteger(content.size())); + pdf::PDFStream contentStream(std::move(contentDictionary), std::move(content)); + const pdf::PDFObjectReference contentReference = + builder.addObject(pdf::PDFObject::createStream(std::make_shared(contentStream))); + + pdf::PDFDictionary fonts; + fonts.addEntry(pdf::PDFInplaceOrMemoryString("F1"), pdf::PDFObject::createReference(font)); + pdf::PDFDictionary resources; + resources.addEntry(pdf::PDFInplaceOrMemoryString("Font"), + pdf::PDFObject::createDictionary(std::make_shared(std::move(fonts)))); + + pdf::PDFDictionary pageUpdate; + pageUpdate.addEntry(pdf::PDFInplaceOrMemoryString("Resources"), + pdf::PDFObject::createDictionary(std::make_shared(std::move(resources)))); + pageUpdate.addEntry(pdf::PDFInplaceOrMemoryString("Contents"), pdf::PDFObject::createReference(contentReference)); + builder.mergeTo(page, pdf::PDFObject::createDictionary(std::make_shared(std::move(pageUpdate)))); + + return builder.build(); +} + +pdfinteraction::PointerIntent makePointer(pdfinteraction::PointerAction action, + QPoint positionPx, + quint64 sequence, + Qt::MouseButton button = Qt::NoButton, + Qt::MouseButtons buttons = Qt::NoButton) +{ + pdfinteraction::PointerIntent intent; + intent.stamp.sequence = sequence; + intent.stamp.monotonicNs = qint64(sequence) * 1000000; + intent.action = action; + intent.positionPx = positionPx; + intent.button = button; + intent.buttons = buttons; + return intent; +} + +/// A viewport large enough for one page at the injected density, so a test +/// never depends on the host monitor. +void configureViewport(pdfinteraction::ViewportController& viewport) +{ + viewport.setPixelPerMM(4.0); + viewport.setDevicePixelRatio(1.0); + viewport.setViewportSizePx(QSize(1000, 1000)); + viewport.setZoom(1.0); +} + +} // namespace + class DocumentViewSessionTest final : public QObject { Q_OBJECT @@ -20,6 +164,11 @@ private slots: void defaultCacheLimitPartitions(); void cacheLimitNormalizesAndReappliesAfterDocumentReplacement(); void denyExtraGraphicsSyncsFromRenderFeatures(); + void measurementRespectsUserUnitAndPageRotation(); + void copiedTextFollowsReadingOrderOnAMultiColumnPage(); + void selectionOnAPageWithNoTextLayerReportsThatState(); + void revisionChangeDropsMeasurementsAndTextSelection(); + void imageExtractionReportsThePagesImages(); }; void DocumentViewSessionTest::defaultCacheLimitPartitions() @@ -83,6 +232,184 @@ void DocumentViewSessionTest::denyExtraGraphicsSyncsFromRenderFeatures() QVERIFY(!session.overlays()->denyExtraGraphics()); } +void DocumentViewSessionTest::measurementRespectsUserUnitAndPageRotation() +{ + pdf::PDFDocument document = makeRotatedUserUnitDocument(); + + DocumentViewSession session; + session.context().setDocument(&document); + session.prepareDocumentView(); + configureViewport(session.viewport()); + + pdfinteraction::InteractionController* interaction = session.interaction(); + QVERIFY(interaction != nullptr); + interaction->setActiveTool(pdfinteraction::InteractionTool::Measure); + + const QPointF startPagePoint(50.0, 50.0); + const QPointF endPagePoint(150.0, 150.0); + + const QPoint fromPx = session.viewport().pagePointToViewportMatrix(0).map(startPagePoint).toPoint(); + const QPoint toPx = session.viewport().pagePointToViewportMatrix(0).map(endPagePoint).toPoint(); + + interaction->handlePointer(makePointer(pdfinteraction::PointerAction::Press, fromPx, 1, Qt::LeftButton, Qt::LeftButton)); + interaction->handlePointer(makePointer(pdfinteraction::PointerAction::Move, toPx, 2, Qt::NoButton, Qt::LeftButton)); + interaction->handlePointer(makePointer(pdfinteraction::PointerAction::Release, toPx, 3, Qt::LeftButton)); + + QCOMPARE(interaction->state().measurements().size(), 1); + const pdfinteraction::Measurement measurement = interaction->state().measurements().constFirst(); + + // The reference is the retired dimensions plugin's own formula -- the + // page-space length times /UserUnit, converted to millimetres -- within the + // tolerance the pointer round trip through integer pixels costs. + const qreal referenceMM = std::hypot(100.0, 100.0) * pdf::PDF_POINT_TO_MM * 2.0; + QVERIFY(qAbs(measurement.distanceMM - referenceMM) <= 1.0); + QCOMPARE(measurement.pointToMM, pdf::PDF_POINT_TO_MM * 2.0); + + // The same page-space segment under the opposite viewer rotation measures + // the same: a page-space segment is rotation invariant, and the controller + // resolves the points through the viewport's own transforms. + session.viewport().setRotation(pdf::PageRotation::Rotate270); + const QPoint fromRotated = session.viewport().pagePointToViewportMatrix(0).map(startPagePoint).toPoint(); + const QPoint toRotated = session.viewport().pagePointToViewportMatrix(0).map(endPagePoint).toPoint(); + + interaction->handlePointer(makePointer(pdfinteraction::PointerAction::Press, fromRotated, 4, Qt::LeftButton, Qt::LeftButton)); + interaction->handlePointer(makePointer(pdfinteraction::PointerAction::Move, toRotated, 5, Qt::NoButton, Qt::LeftButton)); + interaction->handlePointer(makePointer(pdfinteraction::PointerAction::Release, toRotated, 6, Qt::LeftButton)); + + QCOMPARE(interaction->state().measurements().size(), 2); + const pdfinteraction::Measurement rotated = interaction->state().measurements().constLast(); + QVERIFY(qAbs(rotated.distanceMM - measurement.distanceMM) <= 1.0); +} + +void DocumentViewSessionTest::copiedTextFollowsReadingOrderOnAMultiColumnPage() +{ + pdf::PDFDocument document = makeTwoColumnTextDocument(); + + DocumentViewSession session; + session.context().setDocument(&document); + session.prepareDocumentView(); + configureViewport(session.viewport()); + + pdfinteraction::InteractionController* interaction = session.interaction(); + QVERIFY(interaction != nullptr); + + QVERIFY(interaction->selectAllText()); + + const std::optional& selection = interaction->state().textSelection(); + QVERIFY(selection.has_value()); + QVERIFY(selection->available); + QVERIFY(selection->isValid()); + QVERIFY(!selection->geometry.isEmpty()); + + // Column-major reading order, as Core's text layer orders the blocks: every + // line of the left column first, then the right column's. A row-major order + // would interleave them ("LEFT ONE", "RIGHT ONE", "LEFT TWO", ...). Each + // line is its own block in this fixture, and the whole-page selection joins + // the blocks' texts with a newline. + QCOMPARE(selection->text, QStringLiteral("LEFT ONE\nLEFT TWO\nRIGHT ONE\nRIGHT TWO")); + + const pdfinteraction::TextCopyResult copy = interaction->selectedText(); + QVERIFY(copy.copied); + QCOMPARE(copy.text, selection->text); + + // Stepping by line walks the same order: a whole-page selection already + // ends on the last line, so stepping forward is refused, and the first line + // is reachable by deselecting and stepping forward again. + QVERIFY(!interaction->stepTextSelectionByLine(1)); + QVERIFY(interaction->deselectText()); + QVERIFY(interaction->stepTextSelectionByLine(1)); + QCOMPARE(interaction->state().textSelection()->text, QStringLiteral("LEFT ONE")); + QVERIFY(interaction->stepTextSelectionByLine(1)); + QCOMPARE(interaction->state().textSelection()->text, QStringLiteral("LEFT TWO")); +} + +void DocumentViewSessionTest::selectionOnAPageWithNoTextLayerReportsThatState() +{ + pdf::PDFDocument document = makeImageOnlyDocument(); + + DocumentViewSession session; + session.context().setDocument(&document); + session.prepareDocumentView(); + configureViewport(session.viewport()); + + pdfinteraction::InteractionController* interaction = session.interaction(); + QVERIFY(interaction != nullptr); + + // The attempt stores a typed selection that says why there is nothing. + QVERIFY(interaction->selectAllText()); + + const std::optional& selection = interaction->state().textSelection(); + QVERIFY(selection.has_value()); + QVERIFY(!selection->available); + QCOMPARE(selection->unavailableReason, QStringLiteral("This page has no text layer.")); + QVERIFY(selection->text.isEmpty()); + + // And Copy reports that state instead of copying an empty string. + const pdfinteraction::TextCopyResult copy = interaction->selectedText(); + QVERIFY(!copy.copied); + QCOMPARE(copy.refusalCode, QStringLiteral("text/no-text-layer")); + QVERIFY(copy.text.isEmpty()); +} + +void DocumentViewSessionTest::revisionChangeDropsMeasurementsAndTextSelection() +{ + pdf::PDFDocument first = makeRotatedUserUnitDocument(); + pdf::PDFDocument second = makeTwoColumnTextDocument(); + + DocumentViewSession session; + session.context().setDocument(&first); + session.prepareDocumentView(); + configureViewport(session.viewport()); + + pdfinteraction::InteractionController* interaction = session.interaction(); + QVERIFY(interaction != nullptr); + + interaction->setActiveTool(pdfinteraction::InteractionTool::Measure); + const QPoint fromPx = session.viewport().pagePointToViewportMatrix(0).map(QPointF(50.0, 50.0)).toPoint(); + const QPoint toPx = session.viewport().pagePointToViewportMatrix(0).map(QPointF(150.0, 150.0)).toPoint(); + interaction->handlePointer(makePointer(pdfinteraction::PointerAction::Press, fromPx, 1, Qt::LeftButton, Qt::LeftButton)); + interaction->handlePointer(makePointer(pdfinteraction::PointerAction::Move, toPx, 2, Qt::NoButton, Qt::LeftButton)); + interaction->handlePointer(makePointer(pdfinteraction::PointerAction::Release, toPx, 3, Qt::LeftButton)); + + QCOMPARE(interaction->state().measurements().size(), 1); + + // A revision change, delivered the way the shell delivers one: the context + // replaces the document and the session's own wiring drops both derived + // facts. + session.context().setDocument(&second); + + QVERIFY(interaction->state().measurements().isEmpty()); + QVERIFY(!interaction->state().measurementDraft().has_value()); + QVERIFY(!interaction->state().textSelection().has_value()); +} + +void DocumentViewSessionTest::imageExtractionReportsThePagesImages() +{ + pdf::PDFDocument document = makeImageOnlyDocument(); + + DocumentViewSession session; + session.context().setDocument(&document); + session.prepareDocumentView(); + + pdfinteraction::PDFDocumentImageExtractor* extractor = session.imageExtractor(); + QVERIFY(extractor != nullptr); + + QString typedError; + const QList images = extractor->extractPage(0, &typedError); + + QVERIFY(typedError.isEmpty()); + QCOMPARE(images.size(), 1); + QCOMPARE(images.constFirst().pageIndex, 0); + QCOMPARE(images.constFirst().order, 0); + QCOMPARE(images.constFirst().image.size(), QSize(2, 2)); + QVERIFY(!images.constFirst().image.isNull()); + + // A page index that does not exist is a typed refusal, not an empty success. + const QList missing = extractor->extractPage(7, &typedError); + QVERIFY(missing.isEmpty()); + QVERIFY(!typedError.isEmpty()); +} + QTEST_GUILESS_MAIN(DocumentViewSessionTest) #include "tst_documentviewsessiontest.moc" diff --git a/UnitTests/tst_interactioncontrollertest.cpp b/UnitTests/tst_interactioncontrollertest.cpp index edef4dcbf..869d13c86 100644 --- a/UnitTests/tst_interactioncontrollertest.cpp +++ b/UnitTests/tst_interactioncontrollertest.cpp @@ -36,6 +36,7 @@ #include #include +#include #include #include "hittestsource.h" @@ -124,6 +125,130 @@ class FakeRevisionSource final : public pdfinteraction::IDocumentRevisionSource pdf::PDFRevisionIdentity revision = makeRevision(QStringLiteral("doc-1")); }; +/// The page's own /UserUnit scale, injected so a measurement test does not need +/// a PDF. The default is the un-scaled point-to-millimetre factor. +class FakePageUnitSource final : public pdfinteraction::IPageUnitSource +{ +public: + bool pagePointToMM(int pageIndex, qreal* factor) const override + { + if (!factor || pageIndex < 0 || pageIndex >= pageCount) + { + return false; + } + + *factor = pointToMM; + return true; + } + + int pageCount = 3; + qreal pointToMM = pdf::PDF_POINT_TO_MM; +}; + +/// Scripted text extraction, so the controller's selection, stepping and copy +/// behavior is provable without a document. The Core-backed source is exercised +/// separately against a real document in UnitTestsDocumentViewSession. +class ScriptedTextSelectionSource final : public pdfinteraction::ITextSelectionSource +{ +public: + bool hasTextLayer(int pageIndex) const override + { + Q_UNUSED(pageIndex); + return available; + } + + pdfinteraction::TextSelectionResult selectRange(int pageIndex, QPointF startPagePoint, QPointF endPagePoint) override + { + Q_UNUSED(pageIndex); + lastRangeStart = startPagePoint; + lastRangeEnd = endPagePoint; + + pdfinteraction::TextSelectionResult result = baseResult(); + if (!result.available) + { + return result; + } + + result.text = rangeText; + result.geometry = { QRectF(startPagePoint, endPagePoint).normalized() }; + result.firstLine = 0; + result.lastLine = 1; + return result; + } + + pdfinteraction::TextSelectionResult selectPage(int pageIndex) override + { + Q_UNUSED(pageIndex); + pdfinteraction::TextSelectionResult result = baseResult(); + if (result.available) + { + result.text = pageText; + result.geometry = { QRectF(0.0, 0.0, 50.0, 50.0) }; + result.firstLine = 0; + result.lastLine = lineTexts.size() - 1; + } + return result; + } + + pdfinteraction::TextSelectionResult selectBlockAt(int pageIndex, QPointF pagePoint) override + { + Q_UNUSED(pageIndex); + lastBlockPoint = pagePoint; + + pdfinteraction::TextSelectionResult result = baseResult(); + if (result.available) + { + result.text = blockText; + result.geometry = { QRectF(10.0, 10.0, 20.0, 20.0) }; + result.firstLine = 0; + result.lastLine = 0; + } + return result; + } + + int lineCount(int pageIndex) const override + { + Q_UNUSED(pageIndex); + return available ? lineTexts.size() : -1; + } + + pdfinteraction::TextSelectionResult selectLine(int pageIndex, int lineIndex) override + { + Q_UNUSED(pageIndex); + + pdfinteraction::TextSelectionResult result = baseResult(); + if (result.available && lineIndex >= 0 && lineIndex < lineTexts.size()) + { + result.text = lineTexts.at(lineIndex); + result.geometry = { QRectF(0.0, qreal(lineIndex) * 10.0, 50.0, 8.0) }; + result.firstLine = lineIndex; + result.lastLine = lineIndex; + } + return result; + } + + bool available = true; + QString rangeText = QStringLiteral("selected text"); + QString pageText = QStringLiteral("whole page"); + QString blockText = QStringLiteral("block text"); + QStringList lineTexts = { QStringLiteral("line one"), QStringLiteral("line two") }; + QPointF lastRangeStart; + QPointF lastRangeEnd; + QPointF lastBlockPoint; + +private: + pdfinteraction::TextSelectionResult baseResult() const + { + pdfinteraction::TextSelectionResult result; + result.available = available; + if (!available) + { + result.unavailableReason = QStringLiteral("This page has no text layer."); + } + return result; + } +}; + /// Returns targets a test wrote down, so hit-test precedence is provable without /// a corpus. EvidenceHitTestSource is exercised separately against a real /// pdf::PDFEvidenceGraph. @@ -228,6 +353,19 @@ private Q_SLOTS: void traceReplayReproducesState(); void traceCarriesNoDocumentPayload(); + void measureToolMeasuresInProductionUnits(); + void measureToolReportsNoDragSessionOrTargetSelection(); + void measureToolClickWithoutTravelRecordsNothing(); + void measureToolRefusesWithoutAPageScale(); + void revisionChangeClearsMeasurementsAndTextSelection(); + void textToolStoresTypedSelectionFromTheSource(); + void textToolClickClearsTheSelection(); + void copyReturnsSelectedTextAndNeverAnEmptyString(); + void copyRefusesOnAPageWithNoTextLayer(); + void selectAllAndDeselectText(); + void steppingByLineWalksTheTextLayer(); + void tableSelectionUsesTheBlockUnderThePointer(); + private: void setViewport(); pdfinteraction::InteractionTarget objectTarget() const; @@ -239,6 +377,8 @@ private Q_SLOTS: std::unique_ptr m_dispatcher; std::unique_ptr m_overlays; std::unique_ptr m_controller; + std::unique_ptr m_units; + std::unique_ptr m_textSource; }; void InteractionControllerTest::init() @@ -262,11 +402,18 @@ void InteractionControllerTest::init() m_overlays = std::make_unique(*m_viewport); m_controller = std::make_unique(*m_revisions, *m_viewport, *m_dispatcher, *m_overlays); + + m_units = std::make_unique(); + m_textSource = std::make_unique(); + m_controller->setPageUnitSource(m_units.get()); + m_controller->setTextSelectionSource(m_textSource.get()); } void InteractionControllerTest::cleanup() { m_controller.reset(); + m_textSource.reset(); + m_units.reset(); m_overlays.reset(); m_dispatcher.reset(); m_targets.reset(); @@ -280,12 +427,16 @@ pdfinteraction::InteractionTarget InteractionControllerTest::objectTarget() cons return makeTarget(pdfinteraction::InteractionTargetKind::Finding, QStringLiteral("finding-a"), QRectF(20.0, 20.0, 20.0, 20.0)); } -/// Viewport pixel for a page-space point on page 0, through the same matrix the -/// page surfaces use. Computing it any other way would test the test's -/// arithmetic rather than the controller's. +/// A viewport pixel for a page-space point on page 0, through the same matrix +/// the page surfaces use. +static QPoint viewportPointFor(const pdfinteraction::ViewportController& viewport, int pageIndex, QPointF pagePoint) +{ + return viewport.pagePointToViewportMatrix(pageIndex).map(pagePoint).toPoint(); +} + static QPoint viewportPointFor(const pdfinteraction::ViewportController& viewport, QPointF pagePoint) { - return viewport.pagePointToViewportMatrix(0).map(pagePoint).toPoint(); + return viewportPointFor(viewport, 0, pagePoint); } void InteractionControllerTest::hoverChangeIsOverlayOnly() @@ -510,12 +661,16 @@ void InteractionControllerTest::toolVocabularyNamesRoundTrip() QCOMPARE(QString::fromLatin1(pdfinteraction::getInteractionToolName(InteractionTool::Select)), QStringLiteral("select")); QCOMPARE(QString::fromLatin1(pdfinteraction::getInteractionToolName(InteractionTool::Hand)), QStringLiteral("hand")); + QCOMPARE(QString::fromLatin1(pdfinteraction::getInteractionToolName(InteractionTool::Text)), QStringLiteral("text")); + QCOMPARE(QString::fromLatin1(pdfinteraction::getInteractionToolName(InteractionTool::Measure)), QStringLiteral("measure")); QCOMPARE(pdfinteraction::interactionToolFromName(QStringLiteral("select")).value_or(InteractionTool::Hand), InteractionTool::Select); QCOMPARE(pdfinteraction::interactionToolFromName(QStringLiteral("hand")).value_or(InteractionTool::Select), InteractionTool::Hand); + QCOMPARE(pdfinteraction::interactionToolFromName(QStringLiteral("text")).value_or(InteractionTool::Hand), InteractionTool::Text); + QCOMPARE(pdfinteraction::interactionToolFromName(QStringLiteral("measure")).value_or(InteractionTool::Hand), InteractionTool::Measure); // A name outside the vocabulary is refused, never coerced into a default. - QVERIFY(!pdfinteraction::interactionToolFromName(QStringLiteral("measure")).has_value()); + QVERIFY(!pdfinteraction::interactionToolFromName(QStringLiteral("lasso")).has_value()); QVERIFY(!pdfinteraction::interactionToolFromName(QString()).has_value()); } @@ -1180,6 +1335,333 @@ void InteractionControllerTest::traceCarriesNoDocumentPayload() QVERIFY(!traceJson.contains("text")); } +/// Page-space tolerance for a measurement that made a round trip through +/// integer viewport pixels. Half a page unit at two pixels per unit is a quarter +/// of a pixel, and the /UserUnit 2 fixture doubles it; 0.5 mm is the stated +/// tolerance the acceptance asks for. +constexpr qreal MeasurementToleranceMM = 0.5; + +void InteractionControllerTest::measureToolMeasuresInProductionUnits() +{ + // A /UserUnit 2 page: the same page-space points span twice the physical + // distance, and the readout must say so. + m_units->pointToMM = pdf::PDF_POINT_TO_MM * 2.0; + m_controller->setActiveTool(pdfinteraction::InteractionTool::Measure); + + const QPointF startPagePoint(10.0, 10.0); + const QPointF endPagePoint(40.0, 50.0); + + QSignalSpy measureSpy(m_controller.get(), &pdfinteraction::InteractionController::measurementCompleted); + + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Press, viewportPointFor(*m_viewport, startPagePoint), 1, Qt::LeftButton, Qt::LeftButton)); + QVERIFY(m_controller->state().measurementDraft().has_value()); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Move, viewportPointFor(*m_viewport, endPagePoint), 2, Qt::NoButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Release, viewportPointFor(*m_viewport, endPagePoint), 3, Qt::LeftButton)); + + QCOMPARE(measureSpy.size(), 1); + QCOMPARE(m_controller->state().measurements().size(), 1); + + const pdfinteraction::Measurement measurement = m_controller->state().measurements().constFirst(); + const qreal referenceMM = + std::hypot(endPagePoint.x() - startPagePoint.x(), endPagePoint.y() - startPagePoint.y()) * m_units->pointToMM; + + QVERIFY(qAbs(measurement.distanceMM - referenceMM) <= MeasurementToleranceMM); + QVERIFY(measurement.isValid()); + QCOMPARE(measurement.fence, m_controller->token()); + QCOMPARE(measurement.pointToMM, m_units->pointToMM); + QCOMPARE(pdfinteraction::measurementReadout(measurement), QStringLiteral("35.3 mm")); + + // The region the two points span is reported in the same units. + QVERIFY(qAbs(measurement.widthMM - 30.0 * m_units->pointToMM) <= MeasurementToleranceMM); + QVERIFY(qAbs(measurement.heightMM - 40.0 * m_units->pointToMM) <= MeasurementToleranceMM); + QVERIFY(qAbs(measurement.perimeterMM - 2.0 * (measurement.widthMM + measurement.heightMM)) <= MeasurementToleranceMM); + QVERIFY(qAbs(measurement.areaMM2 - measurement.widthMM * measurement.heightMM) <= 1.0); +} + +void InteractionControllerTest::measureToolReportsNoDragSessionOrTargetSelection() +{ + m_controller->setActiveTool(pdfinteraction::InteractionTool::Measure); + + QSignalSpy dragSpy(m_controller.get(), &pdfinteraction::InteractionController::dragCompleted); + QSignalSpy selectionSpy(m_controller.get(), &pdfinteraction::InteractionController::selectionChanged); + const quint64 generationBefore = m_viewport->requestGeneration(); + const pdf::PDFRevisionIdentity revisionBefore = m_revisions->currentRevision(); + + const QPointF startPagePoint(10.0, 10.0); + const QPointF endPagePoint(40.0, 50.0); + + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Press, viewportPointFor(*m_viewport, startPagePoint), 1, Qt::LeftButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Move, viewportPointFor(*m_viewport, endPagePoint), 2, Qt::NoButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Release, viewportPointFor(*m_viewport, endPagePoint), 3, Qt::LeftButton)); + + // A measurement is presentation state: no DragSession, no target selection, + // no document revision change and no page render demand. + QCOMPARE(dragSpy.size(), 0); + QCOMPARE(selectionSpy.size(), 0); + QVERIFY(!m_controller->state().drag().has_value()); + QVERIFY(!m_controller->state().selected().isValid()); + QCOMPARE(m_revisions->currentRevision(), revisionBefore); + QCOMPARE(m_viewport->requestGeneration(), generationBefore); +} + +void InteractionControllerTest::measureToolClickWithoutTravelRecordsNothing() +{ + m_controller->setActiveTool(pdfinteraction::InteractionTool::Measure); + + const QPoint point = viewportPointFor(*m_viewport, QPointF(30.0, 30.0)); + + QSignalSpy measureSpy(m_controller.get(), &pdfinteraction::InteractionController::measurementCompleted); + + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Press, point, 1, Qt::LeftButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Release, point, 2, Qt::LeftButton)); + + // Below the hysteresis threshold: no zero-length measurement is recorded. + QCOMPARE(measureSpy.size(), 0); + QVERIFY(m_controller->state().measurements().isEmpty()); + QVERIFY(!m_controller->state().measurementDraft().has_value()); +} + +void InteractionControllerTest::measureToolRefusesWithoutAPageScale() +{ + const QPointF startPagePoint(10.0, 10.0); + const QPointF endPagePoint(40.0, 50.0); + + m_controller->setActiveTool(pdfinteraction::InteractionTool::Measure); + + // A controller with no unit source must refuse the gesture rather than + // measure at a guessed 1.0. + m_controller->setPageUnitSource(nullptr); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Press, viewportPointFor(*m_viewport, startPagePoint), 1, Qt::LeftButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Move, viewportPointFor(*m_viewport, endPagePoint), 2, Qt::NoButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Release, viewportPointFor(*m_viewport, endPagePoint), 3, Qt::LeftButton)); + + QVERIFY(m_controller->state().measurements().isEmpty()); + QVERIFY(!m_controller->state().measurementDraft().has_value()); + + // And a source that cannot resolve the page refuses the same way. + m_units->pageCount = 0; + m_controller->setPageUnitSource(m_units.get()); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Press, viewportPointFor(*m_viewport, startPagePoint), 4, Qt::LeftButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Move, viewportPointFor(*m_viewport, endPagePoint), 5, Qt::NoButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Release, viewportPointFor(*m_viewport, endPagePoint), 6, Qt::LeftButton)); + + QVERIFY(m_controller->state().measurements().isEmpty()); + QVERIFY(!m_controller->state().measurementDraft().has_value()); +} + +void InteractionControllerTest::revisionChangeClearsMeasurementsAndTextSelection() +{ + // A completed measurement... + m_controller->setActiveTool(pdfinteraction::InteractionTool::Measure); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Press, viewportPointFor(*m_viewport, QPointF(10.0, 10.0)), 1, Qt::LeftButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Move, viewportPointFor(*m_viewport, QPointF(40.0, 50.0)), 2, Qt::NoButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Release, viewportPointFor(*m_viewport, QPointF(40.0, 50.0)), 3, Qt::LeftButton)); + + // ...and a text selection, both taken against the current revision. + m_controller->setActiveTool(pdfinteraction::InteractionTool::Text); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Press, viewportPointFor(*m_viewport, QPointF(20.0, 20.0)), 4, Qt::LeftButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Move, viewportPointFor(*m_viewport, QPointF(60.0, 60.0)), 5, Qt::NoButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Release, viewportPointFor(*m_viewport, QPointF(60.0, 60.0)), 6, Qt::LeftButton)); + + QCOMPARE(m_controller->state().measurements().size(), 1); + QVERIFY(m_controller->state().textSelection().has_value()); + + QSignalSpy clearedSpy(m_controller.get(), &pdfinteraction::InteractionController::measurementsCleared); + QSignalSpy textSpy(m_controller.get(), &pdfinteraction::InteractionController::textSelectionChanged); + + // The document revision moved under both facts. + m_revisions->revision = makeRevision(QStringLiteral("doc-1"), 2); + m_controller->handleRevisionChanged(); + + // Neither is carried onto geometry the operator never saw. + QVERIFY(m_controller->state().measurements().isEmpty()); + QVERIFY(!m_controller->state().measurementDraft().has_value()); + QVERIFY(!m_controller->state().textSelection().has_value()); + QCOMPARE(clearedSpy.size(), 1); + QCOMPARE(textSpy.size(), 1); + + const pdfinteraction::TextCopyResult refused = m_controller->selectedText(); + QVERIFY(!refused.copied); + QCOMPARE(refused.refusalCode, QStringLiteral("text/nothing-selected")); +} + +void InteractionControllerTest::textToolStoresTypedSelectionFromTheSource() +{ + m_controller->setActiveTool(pdfinteraction::InteractionTool::Text); + + QSignalSpy targetSpy(m_controller.get(), &pdfinteraction::InteractionController::selectionChanged); + QSignalSpy textSpy(m_controller.get(), &pdfinteraction::InteractionController::textSelectionChanged); + + const QPointF startPagePoint(20.0, 20.0); + const QPointF endPagePoint(60.0, 60.0); + + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Press, viewportPointFor(*m_viewport, startPagePoint), 1, Qt::LeftButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Move, viewportPointFor(*m_viewport, endPagePoint), 2, Qt::NoButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Release, viewportPointFor(*m_viewport, endPagePoint), 3, Qt::LeftButton)); + + // Announced once, when the gesture completed, not on every pointer delta. + QCOMPARE(textSpy.size(), 1); + + // A text selection never selects a target: text is not an + // InteractionTargetKind. + QCOMPARE(targetSpy.size(), 0); + QVERIFY(!m_controller->state().selected().isValid()); + + const std::optional& selection = m_controller->state().textSelection(); + QVERIFY(selection.has_value()); + QVERIFY(selection->isValid()); + QVERIFY(selection->available); + QCOMPARE(selection->kind, pdfinteraction::TextSelectionKind::Text); + QCOMPARE(selection->pageIndex, 0); + QCOMPARE(selection->text, m_textSource->rangeText); + QCOMPARE(selection->firstLine, 0); + QCOMPARE(selection->lastLine, 1); + QVERIFY(!selection->geometry.isEmpty()); + QCOMPARE(selection->fence, m_controller->token()); + + // The source was asked in page space, through the viewport's own transform. + QVERIFY(qAbs(m_textSource->lastRangeStart.x() - startPagePoint.x()) <= 1.0); + QVERIFY(qAbs(m_textSource->lastRangeEnd.x() - endPagePoint.x()) <= 1.0); + QVERIFY(qAbs(m_textSource->lastRangeEnd.y() - endPagePoint.y()) <= 1.0); +} + +void InteractionControllerTest::textToolClickClearsTheSelection() +{ + m_controller->setActiveTool(pdfinteraction::InteractionTool::Text); + + const QPointF startPagePoint(20.0, 20.0); + const QPointF endPagePoint(60.0, 60.0); + + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Press, viewportPointFor(*m_viewport, startPagePoint), 1, Qt::LeftButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Move, viewportPointFor(*m_viewport, endPagePoint), 2, Qt::NoButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Release, viewportPointFor(*m_viewport, endPagePoint), 3, Qt::LeftButton)); + + QVERIFY(m_controller->state().textSelection().has_value()); + + // A press and release without travel is a click: it clears the selection + // rather than leaving a zero-length one behind. + const QPoint click = viewportPointFor(*m_viewport, QPointF(30.0, 30.0)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Press, click, 4, Qt::LeftButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Release, click, 5, Qt::LeftButton)); + + QVERIFY(!m_controller->state().textSelection().has_value()); +} + +void InteractionControllerTest::copyReturnsSelectedTextAndNeverAnEmptyString() +{ + // Nothing is selected: the refusal is typed and the text is empty. + const pdfinteraction::TextCopyResult nothing = m_controller->selectedText(); + QVERIFY(!nothing.copied); + QCOMPARE(nothing.refusalCode, QStringLiteral("text/nothing-selected")); + QVERIFY(nothing.text.isEmpty()); + QVERIFY(!nothing.refusalReason.isEmpty()); + + m_controller->setActiveTool(pdfinteraction::InteractionTool::Text); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Press, viewportPointFor(*m_viewport, QPointF(20.0, 20.0)), 1, Qt::LeftButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Move, viewportPointFor(*m_viewport, QPointF(60.0, 60.0)), 2, Qt::NoButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Release, viewportPointFor(*m_viewport, QPointF(60.0, 60.0)), 3, Qt::LeftButton)); + + const pdfinteraction::TextCopyResult copied = m_controller->selectedText(); + QVERIFY(copied.copied); + QCOMPARE(copied.text, m_textSource->rangeText); + QVERIFY(copied.refusalCode.isEmpty()); + + // A selection that carries no text is refused, never copied as an empty + // success. + m_textSource->rangeText.clear(); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Press, viewportPointFor(*m_viewport, QPointF(20.0, 20.0)), 4, Qt::LeftButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Move, viewportPointFor(*m_viewport, QPointF(60.0, 60.0)), 5, Qt::NoButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Release, viewportPointFor(*m_viewport, QPointF(60.0, 60.0)), 6, Qt::LeftButton)); + + const pdfinteraction::TextCopyResult empty = m_controller->selectedText(); + QVERIFY(!empty.copied); + QCOMPARE(empty.refusalCode, QStringLiteral("text/nothing-selected")); + QVERIFY(empty.text.isEmpty()); +} + +void InteractionControllerTest::copyRefusesOnAPageWithNoTextLayer() +{ + m_textSource->available = false; + m_controller->setActiveTool(pdfinteraction::InteractionTool::Text); + + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Press, viewportPointFor(*m_viewport, QPointF(20.0, 20.0)), 1, Qt::LeftButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Move, viewportPointFor(*m_viewport, QPointF(60.0, 60.0)), 2, Qt::NoButton, Qt::LeftButton)); + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Release, viewportPointFor(*m_viewport, QPointF(60.0, 60.0)), 3, Qt::LeftButton)); + + // The selection reports the state instead of pretending to be empty. + const std::optional& selection = m_controller->state().textSelection(); + QVERIFY(selection.has_value()); + QVERIFY(!selection->available); + QCOMPARE(selection->unavailableReason, QStringLiteral("This page has no text layer.")); + QVERIFY(selection->text.isEmpty()); + + const pdfinteraction::TextCopyResult result = m_controller->selectedText(); + QVERIFY(!result.copied); + QCOMPARE(result.refusalCode, QStringLiteral("text/no-text-layer")); + QVERIFY(result.text.isEmpty()); +} + +void InteractionControllerTest::selectAllAndDeselectText() +{ + QSignalSpy textSpy(m_controller.get(), &pdfinteraction::InteractionController::textSelectionChanged); + + QVERIFY(m_controller->selectAllText()); + QCOMPARE(textSpy.size(), 1); + + const std::optional& selection = m_controller->state().textSelection(); + QVERIFY(selection.has_value()); + QVERIFY(selection->available); + QCOMPARE(selection->kind, pdfinteraction::TextSelectionKind::Text); + QCOMPARE(selection->text, m_textSource->pageText); + QCOMPARE(selection->firstLine, 0); + QCOMPARE(selection->lastLine, 1); + + QVERIFY(m_controller->deselectText()); + QVERIFY(!m_controller->state().textSelection().has_value()); + QCOMPARE(textSpy.size(), 2); + + // Idempotent: nothing to drop is not a change. + QVERIFY(!m_controller->deselectText()); + QCOMPARE(textSpy.size(), 2); +} + +void InteractionControllerTest::steppingByLineWalksTheTextLayer() +{ + // With no selection, stepping forward selects the first line of the page's + // text layer in reading order. + QVERIFY(m_controller->stepTextSelectionByLine(1)); + QCOMPARE(m_controller->state().textSelection()->text, m_textSource->lineTexts.at(0)); + QCOMPARE(m_controller->state().textSelection()->firstLine, 0); + QCOMPARE(m_controller->state().textSelection()->lastLine, 0); + + QVERIFY(m_controller->stepTextSelectionByLine(1)); + QCOMPARE(m_controller->state().textSelection()->text, m_textSource->lineTexts.at(1)); + + // Past the last line: refused rather than wrapped onto another page. + QVERIFY(!m_controller->stepTextSelectionByLine(1)); + QCOMPARE(m_controller->state().textSelection()->text, m_textSource->lineTexts.at(1)); + + QVERIFY(m_controller->stepTextSelectionByLine(-1)); + QCOMPARE(m_controller->state().textSelection()->text, m_textSource->lineTexts.at(0)); + QVERIFY(!m_controller->stepTextSelectionByLine(-1)); +} + +void InteractionControllerTest::tableSelectionUsesTheBlockUnderThePointer() +{ + // "Under the pointer" needs a pointer position over the page. + m_controller->handlePointer(makePointer(pdfinteraction::PointerAction::Move, viewportPointFor(*m_viewport, QPointF(30.0, 30.0)), 1)); + + QVERIFY(m_controller->selectTableUnderPointer()); + + const std::optional& selection = m_controller->state().textSelection(); + QVERIFY(selection.has_value()); + QVERIFY(selection->available); + QCOMPARE(selection->kind, pdfinteraction::TextSelectionKind::Block); + QCOMPARE(selection->text, m_textSource->blockText); + QVERIFY(qAbs(m_textSource->lastBlockPoint.x() - 30.0) <= 1.0); + QVERIFY(qAbs(m_textSource->lastBlockPoint.y() - 30.0) <= 1.0); +} + QTEST_GUILESS_MAIN(InteractionControllerTest) #include "tst_interactioncontrollertest.moc" diff --git a/UnitTests/tst_overlayframetest.cpp b/UnitTests/tst_overlayframetest.cpp index d08bb198b..a0e80bb65 100644 --- a/UnitTests/tst_overlayframetest.cpp +++ b/UnitTests/tst_overlayframetest.cpp @@ -141,6 +141,8 @@ private Q_SLOTS: void highMarkerCountStaysBounded(); void invalidTokenYieldsAnEmptyFrame(); void denyExtraGraphicsSuppressesOverlays(); + void measurementPaintsInTheGuidesBandWithItsReadout(); + void textSelectionPaintsInTheSelectionBandWithoutATarget(); private: std::unique_ptr m_geometry; @@ -411,6 +413,75 @@ void OverlayFrameTest::invalidTokenYieldsAnEmptyFrame() QVERIFY(!frame.token.isValid()); } +void OverlayFrameTest::measurementPaintsInTheGuidesBandWithItsReadout() +{ + const pdfinteraction::RevisionFencedToken token = makeToken(); + + QVERIFY(m_state.begin(pdfinteraction::InteractionKind::Measure, token)); + + const pdfinteraction::Measurement draft = + pdfinteraction::measureBetween(0, QPointF(10.0, 10.0), QPointF(40.0, 50.0), pdf::PDF_POINT_TO_MM); + QVERIFY(m_state.setMeasurementDraft(draft, token)); + + pdfinteraction::OverlayFrame frame = m_overlays->build(m_state, token); + QVERIFY(frame.isOrdered()); + QCOMPARE(frame.primitives.size(), 1); + + const pdfinteraction::OverlayPrimitive* primitive = findPrimitive(frame, QStringLiteral("measurement#draft")); + QVERIFY(primitive); + QCOMPARE(primitive->layer, pdfinteraction::OverlayLayer::Guides); + QCOMPARE(primitive->kind, pdfinteraction::OverlayPrimitiveKind::Measurement); + QCOMPARE(primitive->label, pdfinteraction::measurementReadout(draft)); + QCOMPARE(primitive->pagePolygon.size(), 2); + QVERIFY(primitive->renderable); + + // Completing it moves the primitive to its stable completed id. + QVERIFY(m_state.completeMeasurement(token).has_value()); + + frame = m_overlays->build(m_state, token); + QVERIFY(findPrimitive(frame, QStringLiteral("measurement#0"))); + QVERIFY(!findPrimitive(frame, QStringLiteral("measurement#draft"))); + QCOMPARE(frame.primitives.size(), 1); +} + +void OverlayFrameTest::textSelectionPaintsInTheSelectionBandWithoutATarget() +{ + const pdfinteraction::RevisionFencedToken token = makeToken(); + + pdfinteraction::TextSelection selection; + selection.kind = pdfinteraction::TextSelectionKind::Text; + selection.pageIndex = 0; + selection.available = true; + selection.text = QStringLiteral("two lines"); + selection.geometry = { QRectF(10.0, 10.0, 40.0, 8.0), QRectF(10.0, 20.0, 40.0, 8.0) }; + QVERIFY(m_state.setTextSelection(selection, token)); + + const pdfinteraction::OverlayFrame frame = m_overlays->build(m_state, token); + QVERIFY(frame.isOrdered()); + QCOMPARE(frame.primitives.size(), 2); + + const pdfinteraction::OverlayPrimitive* first = findPrimitive(frame, QStringLiteral("text-selection#0")); + const pdfinteraction::OverlayPrimitive* second = findPrimitive(frame, QStringLiteral("text-selection#1")); + QVERIFY(first); + QVERIFY(second); + QCOMPARE(first->layer, pdfinteraction::OverlayLayer::Selection); + QCOMPARE(first->kind, pdfinteraction::OverlayPrimitiveKind::Rectangle); + QVERIFY(first->renderable); + + // There is no text target kind, so a selection rectangle is not a routed + // click target. + QVERIFY(!first->target.isValid()); + + // An unavailable selection -- a page with no text layer -- has no geometry + // to paint. + pdfinteraction::TextSelection unavailable; + unavailable.pageIndex = 0; + unavailable.available = false; + unavailable.unavailableReason = QStringLiteral("This page has no text layer."); + QVERIFY(m_state.setTextSelection(unavailable, token)); + QVERIFY(m_overlays->build(m_state, token).isEmpty()); +} + QTEST_GUILESS_MAIN(OverlayFrameTest) #include "tst_overlayframetest.moc" diff --git a/UnitTests/tst_quickaccessibilitytest.cpp b/UnitTests/tst_quickaccessibilitytest.cpp index 0ab0892c9..1ad85d7e4 100644 --- a/UnitTests/tst_quickaccessibilitytest.cpp +++ b/UnitTests/tst_quickaccessibilitytest.cpp @@ -14,6 +14,7 @@ class QuickAccessibilityTest : public QObject private Q_SLOTS: void canvasAccessibleHasNoChildren(); + void canvasAccessibleCarriesTheInteractionReadout(); void focusRestorationRestoresItem(); }; @@ -38,6 +39,33 @@ void QuickAccessibilityTest::canvasAccessibleHasNoChildren() QVERIFY(!iface->text(QAccessible::Description).isEmpty()); } +void QuickAccessibilityTest::canvasAccessibleCarriesTheInteractionReadout() +{ + pdfquick::installLoopCanvasAccessibility(); + + QQuickWindow window; + window.resize(320, 240); + window.show(); + + pdfquick::LoopCanvasItem item(window.contentItem()); + item.setWidth(320); + item.setHeight(240); + + // The host composes the readout into the same summary the canvas exposes: + // a measurement's production-unit value and a text selection's size are + // read out, not derived by the screen reader's client. + item.setAccessibleDocumentSummary( + QStringLiteral("Document canvas. Page 1 of 1. Zoom 100 percent. Selected 12 character(s). Measurements: 1. Latest: 42.3 mm.")); + + QAccessibleInterface* iface = QAccessible::queryAccessibleInterface(&item); + QVERIFY(iface); + QCOMPARE(iface->role(), QAccessible::Canvas); + + const QString description = iface->text(QAccessible::Description); + QVERIFY(description.contains(QStringLiteral("42.3 mm"))); + QVERIFY(description.contains(QStringLiteral("Selected 12 character(s)."))); +} + void QuickAccessibilityTest::focusRestorationRestoresItem() { FocusRestoration restoration; diff --git a/UnitTests/tst_quickcanvastest.cpp b/UnitTests/tst_quickcanvastest.cpp index 7ff10251a..d1315034b 100644 --- a/UnitTests/tst_quickcanvastest.cpp +++ b/UnitTests/tst_quickcanvastest.cpp @@ -51,6 +51,7 @@ #include #include +#include #include #include "canvaspalette.h" @@ -169,6 +170,89 @@ pdfinteraction::InteractionTarget makeTarget(const QString& id, QRectF bounds) return target; } +/// Scripted text extraction, so the canvas-side selection path is provable +/// without a document. The Core-backed source is exercised against a real +/// document in UnitTestsDocumentViewSession. +class ScriptedTextSelectionSource final : public pdfinteraction::ITextSelectionSource +{ +public: + bool hasTextLayer(int pageIndex) const override + { + Q_UNUSED(pageIndex); + return available; + } + + pdfinteraction::TextSelectionResult selectRange(int pageIndex, QPointF startPagePoint, QPointF endPagePoint) override + { + Q_UNUSED(pageIndex); + + pdfinteraction::TextSelectionResult result; + result.available = available; + if (!available) + { + result.unavailableReason = QStringLiteral("This page has no text layer."); + return result; + } + + result.text = QStringLiteral("canvas selection"); + result.geometry = { QRectF(startPagePoint, endPagePoint).normalized() }; + result.firstLine = 0; + result.lastLine = 0; + return result; + } + + pdfinteraction::TextSelectionResult selectPage(int pageIndex) override + { + Q_UNUSED(pageIndex); + return selectRange(0, QPointF(), QPointF()); + } + + pdfinteraction::TextSelectionResult selectBlockAt(int pageIndex, QPointF pagePoint) override + { + Q_UNUSED(pagePoint); + return selectPage(pageIndex); + } + + int lineCount(int pageIndex) const override + { + Q_UNUSED(pageIndex); + return available ? 1 : -1; + } + + pdfinteraction::TextSelectionResult selectLine(int pageIndex, int lineIndex) override + { + Q_UNUSED(lineIndex); + return selectPage(pageIndex); + } + + bool available = true; +}; + +class FakePageUnitSource final : public pdfinteraction::IPageUnitSource +{ +public: + bool pagePointToMM(int pageIndex, qreal* factor) const override + { + if (!factor || pageIndex < 0 || pageIndex >= pageCount) + { + return false; + } + + *factor = pointToMM; + return true; + } + + int pageCount = 2; + qreal pointToMM = pdf::PDF_POINT_TO_MM; +}; + +/// A viewport pixel for a page-space point on page 0, through the same matrix +/// the page surfaces use. +QPointF viewportPointFor(const pdfinteraction::ViewportController& viewport, QPointF pagePoint) +{ + return viewport.pagePointToViewportMatrix(0).map(pagePoint); +} + } // namespace class QuickCanvasTest : public QObject @@ -192,6 +276,10 @@ private Q_SLOTS: void focusLossCancelsTheDrag(); void unboundItemIgnoresInput(); void toolSelectionRoundTrips(); + void textToolDragThroughTheCanvasStoresASelection(); + void measureToolDragThroughTheCanvasReportsProductionUnits(); + void textSelectionOnAPageWithNoTextLayerReportsThatState(); + void canvasCompositionPlacesTilesInDevicePixels(); void admittedTilesReachTheSceneGraph(); void revisionReplacementLeavesNoStaleTile(); @@ -250,6 +338,9 @@ private Q_SLOTS: std::unique_ptr m_source; std::unique_ptr m_item; + std::unique_ptr m_textSource; + std::unique_ptr m_units; + std::unique_ptr m_submitter; std::unique_ptr m_renderer; std::unique_ptr m_surfaces; @@ -278,6 +369,11 @@ void QuickCanvasTest::init() m_controller = std::make_unique(*m_revisions, *m_viewport, *m_hitTest, *m_overlays); + m_textSource = std::make_unique(); + m_units = std::make_unique(); + m_controller->setTextSelectionSource(m_textSource.get()); + m_controller->setPageUnitSource(m_units.get()); + m_item = std::make_unique(); m_item->setSize(QSizeF(400.0, 400.0)); } @@ -285,6 +381,8 @@ void QuickCanvasTest::init() void QuickCanvasTest::cleanup() { m_item.reset(); + m_units.reset(); + m_textSource.reset(); m_window.reset(); m_surfaces.reset(); m_renderer.reset(); @@ -633,6 +731,160 @@ void QuickCanvasTest::toolSelectionRoundTrips() QCOMPARE(m_item->activeTool(), QStringLiteral("hand")); } +// --------------------------------------------------------------------------- +// Issue #232 / #238: the canvas carries the text-selection and measurement +// tools the same way it carries select and hand -- events in, neutral intents +// out, and the typed state read back from the controller. +// --------------------------------------------------------------------------- + +void QuickCanvasTest::textToolDragThroughTheCanvasStoresASelection() +{ + bindItem(); + + QVERIFY(m_item->setActiveTool(QStringLiteral("text"))); + QCOMPARE(m_controller->activeTool(), pdfinteraction::InteractionTool::Text); + + const QPointF from = viewportPointFor(*m_viewport, QPointF(20.0, 20.0)); + const QPointF to = viewportPointFor(*m_viewport, QPointF(60.0, 60.0)); + + QVERIFY(sendMousePress(m_item.get(), from)); + QVERIFY(sendMouseMove(m_item.get(), to)); + QVERIFY(sendMouseRelease(m_item.get(), to)); + + const std::optional& selection = m_controller->state().textSelection(); + QVERIFY(selection.has_value()); + QVERIFY(selection->available); + QCOMPARE(selection->text, QStringLiteral("canvas selection")); + QCOMPARE(selection->pageIndex, 0); + + // The overlay frame carries one rectangle per line span in the Selection + // band, and no page render was asked for. + const pdfinteraction::OverlayFrame& frame = m_controller->overlayFrame(); + QVERIFY(frame.isOrdered()); + int selectionPrimitives = 0; + for (const pdfinteraction::OverlayPrimitive& primitive : frame.primitives) + { + if (primitive.layer == pdfinteraction::OverlayLayer::Selection) + { + ++selectionPrimitives; + } + } + QCOMPARE(selectionPrimitives, selection->geometry.size()); + + // And Copy through the controller returns exactly what was selected. + const pdfinteraction::TextCopyResult copy = m_controller->selectedText(); + QVERIFY(copy.copied); + QCOMPARE(copy.text, QStringLiteral("canvas selection")); +} + +void QuickCanvasTest::measureToolDragThroughTheCanvasReportsProductionUnits() +{ + bindItem(); + + QVERIFY(m_item->setActiveTool(QStringLiteral("measure"))); + QCOMPARE(m_controller->activeTool(), pdfinteraction::InteractionTool::Measure); + + const QPointF from = viewportPointFor(*m_viewport, QPointF(20.0, 20.0)); + const QPointF to = viewportPointFor(*m_viewport, QPointF(60.0, 60.0)); + + QSignalSpy measureSpy(m_controller.get(), &pdfinteraction::InteractionController::measurementCompleted); + + QVERIFY(sendMousePress(m_item.get(), from)); + QVERIFY(sendMouseMove(m_item.get(), to)); + QVERIFY(sendMouseRelease(m_item.get(), to)); + + QCOMPARE(measureSpy.size(), 1); + QCOMPARE(m_controller->state().measurements().size(), 1); + + // The measurement is page-space geometry times the page's own scale, and + // the overlay carries the readout. + const pdfinteraction::Measurement measurement = m_controller->state().measurements().constFirst(); + const qreal referenceMM = std::hypot(40.0, 40.0) * m_units->pointToMM; + QVERIFY(qAbs(measurement.distanceMM - referenceMM) <= 0.5); + + const pdfinteraction::OverlayFrame& frame = m_controller->overlayFrame(); + const pdfinteraction::OverlayPrimitive* primitive = nullptr; + for (const pdfinteraction::OverlayPrimitive& candidate : frame.primitives) + { + if (candidate.kind == pdfinteraction::OverlayPrimitiveKind::Measurement) + { + primitive = &candidate; + } + } + QVERIFY(primitive); + QCOMPARE(primitive->label, pdfinteraction::measurementReadout(measurement)); + QCOMPARE(primitive->layer, pdfinteraction::OverlayLayer::Guides); + + // No DragSession, no command: a measurement is never a document edit. + QVERIFY(!m_controller->state().drag().has_value()); +} + +void QuickCanvasTest::textSelectionOnAPageWithNoTextLayerReportsThatState() +{ + bindItem(); + + m_textSource->available = false; + QVERIFY(m_item->setActiveTool(QStringLiteral("text"))); + + const QPointF from = viewportPointFor(*m_viewport, QPointF(20.0, 20.0)); + const QPointF to = viewportPointFor(*m_viewport, QPointF(60.0, 60.0)); + + QVERIFY(sendMousePress(m_item.get(), from)); + QVERIFY(sendMouseMove(m_item.get(), to)); + QVERIFY(sendMouseRelease(m_item.get(), to)); + + const std::optional& selection = m_controller->state().textSelection(); + QVERIFY(selection.has_value()); + QVERIFY(!selection->available); + QCOMPARE(selection->unavailableReason, QStringLiteral("This page has no text layer.")); + + // Copy refuses with the typed reason and never an empty success. + const pdfinteraction::TextCopyResult copy = m_controller->selectedText(); + QVERIFY(!copy.copied); + QCOMPARE(copy.refusalCode, QStringLiteral("text/no-text-layer")); + QVERIFY(copy.text.isEmpty()); + + // And there is nothing to paint for an unavailable selection. + for (const pdfinteraction::OverlayPrimitive& primitive : m_controller->overlayFrame().primitives) + { + QVERIFY(primitive.layer != pdfinteraction::OverlayLayer::Selection); + } +} + +void QuickCanvasTest::canvasCompositionPlacesTilesInDevicePixels() +{ + // The screenshot path composes the admitted tiles into one image. Pure + // function, no window and no scene graph: the same call the host makes. + pdfinteraction::CanvasSnapshot snapshot; + snapshot.token = pdfinteraction::RevisionFencedToken(); + + QImage red(20, 20, QImage::Format_ARGB32_Premultiplied); + red.fill(Qt::red); + QImage blue(20, 20, QImage::Format_ARGB32_Premultiplied); + blue.fill(Qt::blue); + + pdfinteraction::CanvasTile first; + first.key.pageIndex = 0; + first.pixels = pdfinteraction::makeSurfaceBuffer(red); + first.placedRect = QRect(0, 0, 20, 20); + snapshot.tiles.push_back(first); + + pdfinteraction::CanvasTile second; + second.key.pageIndex = 1; + second.pixels = pdfinteraction::makeSurfaceBuffer(blue); + second.placedRect = QRect(20, 0, 20, 20); + snapshot.tiles.push_back(second); + + const QImage composed = pdfinteraction::composeCanvasImage(snapshot, QRect(0, 0, 40, 20), 1.0); + QVERIFY(!composed.isNull()); + QCOMPARE(composed.size(), QSize(40, 20)); + QCOMPARE(composed.pixelColor(10, 10), QColor(Qt::red)); + QCOMPARE(composed.pixelColor(30, 10), QColor(Qt::blue)); + + // A region no tile intersects is a refusal, not a blank success. + QVERIFY(pdfinteraction::composeCanvasImage(snapshot, QRect(100, 100, 40, 20), 1.0).isNull()); +} + // --------------------------------------------------------------------------- // P4-S6: scene-graph lifecycle and canvas parity. // diff --git a/docs/INTERACTION_CONTRACT.md b/docs/INTERACTION_CONTRACT.md index 836bb5f40..1d95389fe 100644 --- a/docs/INTERACTION_CONTRACT.md +++ b/docs/INTERACTION_CONTRACT.md @@ -2,7 +2,8 @@ Status: P4-S4 (0.2.0 Phase 4). Types live in `LoopLibInteraction/sources/inputintent.h`, `interactiontarget.h`, `interactionstate.h`, `hittestsource.h`, `overlayframe.h`, -`overlaybuilder.h`, `interactiontrace.h`, and `interactioncontroller.h`. +`overlaybuilder.h`, `interactiontrace.h`, `textselectionsource.h`, `pageunitsource.h`, +and `interactioncontroller.h`. Architecture invariant **I24**; test targets `UnitTestsInteractionController` and `UnitTestsOverlayFrame`. Companion to [PAGE_SURFACE_CONTRACT.md](PAGE_SURFACE_CONTRACT.md), which owns page pixels. @@ -88,6 +89,8 @@ A tool is what a left-button gesture means. The vocabulary is closed and lives i | --- | --- | | `select` | Selects the target under the pointer; a second press on the selection begins a drag. The default, and the behavior every gesture had before the vocabulary existed. | | `hand` | Begins an `InteractionKind::Pan`. Never selects, retargets or starts a transform. | +| `text` | Begins an `InteractionKind::TextSelect` and drags out a text selection (issue #232). The result is the typed `TextSelection` state; it never selects a target and never reaches the document. | +| `measure` | Begins an `InteractionKind::Measure` and drags out a measurement in production units (issue #238). It never becomes a `DragSession` and never reaches the document. | A host parses a tool name into `InteractionTool` once, at its own boundary. A name outside the vocabulary is refused there — `interactionToolFromName()` returns nothing — so no @@ -99,6 +102,36 @@ Changing the tool calls `cancelActive(InteractionCancelReason::ToolChanged)` (is so a tool change mid-drag drops the gesture and commits nothing. Setting the tool that is already active is not a change and cancels nothing. +### Text selection and measurement + +Both are derived state, and both are fenced to the revision they were taken from. +`InteractionState` carries them beside the selection, and `InteractionController` drops them +when the fence moves: `handleRevisionChanged()` cancels the active gesture with +`RevisionChanged` and clears the measurements and the text selection, because carrying +either onto new geometry presents a fact about a document the operator is no longer looking +at. A measurement also never produces a `DragSession`: it has no command and no mutation +path behind it. + +Text extraction is Core's. `ITextSelectionSource` is the seam — the interaction layer asks +for text and re-implements no extraction — and `PDFDocumentTextSelectionSource` answers +from the same `PDFTextLayoutGenerator` and text-flow machinery `pdf::searchDocumentText` +uses, so a copied selection is the text layer the document search already agreed on, +including the docstrum reading order. A page with no text layer answers +`available == false` with a reason; Copy then refuses with a typed code and never copies an +empty string as success. + +A measurement's scale comes from `IPageUnitSource` — millimetres per page-space point, +including the page's `/UserUnit` — so a `/UserUnit 2` page measures twice what its raw user +space says. Rotation needs no correction of its own: the two points are page-space points, +resolved through `ViewportController`'s own transforms, so a measurement taken on a rotated +page spans the same page-space segment as the unrotated one. + +Overlays follow the same value-type rule as everything else. A measurement is an +`OverlayPrimitiveKind::Measurement` in the `Guides` band with its readout in `label`; a text +selection is one `Rectangle` per line span in the `Selection` band and carries no target, +because there is no text target kind and clicking selected text is a new selection rather +than a routed command. + ## Hit testing `IHitTestSource` answers for one domain, one page, one page-space point, in no particular diff --git a/docs/LOOP_SHELL_CONTRACT.md b/docs/LOOP_SHELL_CONTRACT.md index 54af8a381..52213fae3 100644 --- a/docs/LOOP_SHELL_CONTRACT.md +++ b/docs/LOOP_SHELL_CONTRACT.md @@ -38,7 +38,7 @@ The LoopEditor shell exposes these workspace IDs: The implemented composition is: ```text -toolbar: open · save/export · undo/redo · select/hand · zoom · preflight · preview +toolbar: open · save/export · undo/redo · select/hand/text/measure · zoom · preflight · preview workspace rail (7 IDs) | workspace stack | Document: pages rail | PDF canvas | contextual inspector dock status: document state · production state · preflight state · page/zoom ``` diff --git a/docs/loop-shell-actions.json b/docs/loop-shell-actions.json index f18322e8b..388e99749 100644 --- a/docs/loop-shell-actions.json +++ b/docs/loop-shell-actions.json @@ -247,9 +247,9 @@ "standard_key": "Copy" }, "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Edit" }, @@ -536,9 +536,9 @@ "standard_key": "Deselect" }, "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -562,9 +562,9 @@ "command": { "label_key": "command.actionExtractImage.label", "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -755,9 +755,9 @@ "standard_key": "MoveToNextLine" }, "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "View" }, @@ -803,9 +803,9 @@ "standard_key": "MoveToPreviousLine" }, "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "View" }, @@ -1316,9 +1316,9 @@ "command": { "label_key": "command.actionScreenshot.label", "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -1329,9 +1329,9 @@ "command": { "label_key": "command.actionSelectTable.label", "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -1342,9 +1342,9 @@ "command": { "label_key": "command.actionSelectText.label", "parameters": [], - "capability": "unclassified", + "capability": "none", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, @@ -1358,9 +1358,9 @@ "standard_key": "SelectAll" }, "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Document" }, diff --git a/docs/shell-handler-coverage.json b/docs/shell-handler-coverage.json index 0ca39c964..4e6ef8475 100644 --- a/docs/shell-handler-coverage.json +++ b/docs/shell-handler-coverage.json @@ -16,7 +16,6 @@ "actionColorGrayscale", "actionColorHighContrast", "actionColorInvert", - "actionCopyText", "actionCreateBitonalDocument", "actionCreateEllipse", "actionCreateFreehandCurve", @@ -38,12 +37,8 @@ "actionCreateTextSquiggly", "actionCreateTextStrikeout", "actionCreateTextUnderline", - "actionDeselectText", "actionEncryption", - "actionExtractImage", "actionFirstPageOnRightSide", - "actionGoToNextLine", - "actionGoToPreviousLine", "actionGotoNextBookmark", "actionGotoPreviousBookmark", "actionInlineText", @@ -61,10 +56,6 @@ "actionRenderOptionTextAntialiasing", "actionRender_to_Images", "actionSanitize", - "actionScreenshot", - "actionSelectTable", - "actionSelectText", - "actionSelectTextAll", "actionSend_by_E_Mail", "actionStickyNoteComment", "actionStickyNoteHelp", diff --git a/scripts/verify-command-catalog.py b/scripts/verify-command-catalog.py index 25115a0a8..7347d8904 100644 --- a/scripts/verify-command-catalog.py +++ b/scripts/verify-command-catalog.py @@ -84,6 +84,15 @@ "actionPageLayoutTwoColumns", "actionPageLayoutTwoPages", "actionProperties", + "actionSelectText", + "actionSelectTextAll", + "actionDeselectText", + "actionSelectTable", + "actionCopyText", + "actionGoToNextLine", + "actionGoToPreviousLine", + "actionScreenshot", + "actionExtractImage", } ) diff --git a/tools/ProductQuickAccessibilitySmoke/qml/Main.qml b/tools/ProductQuickAccessibilitySmoke/qml/Main.qml index 25b34b815..5c25e1cd7 100644 --- a/tools/ProductQuickAccessibilitySmoke/qml/Main.qml +++ b/tools/ProductQuickAccessibilitySmoke/qml/Main.qml @@ -158,6 +158,32 @@ ApplicationWindow { onRejected: if (host && host.focusRestoration) host.focusRestoration.restore() } + FileDialog { + id: screenshotDialog + title: qsTr("Save Screenshot") + fileMode: FileDialog.SaveFile + nameFilters: [qsTr("PNG images (*.png)")] + onAccepted: { + if (host) { + host.captureCanvasFileUrl(selectedFile) + } + if (host && host.focusRestoration) host.focusRestoration.restore() + } + onRejected: if (host && host.focusRestoration) host.focusRestoration.restore() + } + + FolderDialog { + id: extractImagesDialog + title: qsTr("Extract Images Into Folder") + onAccepted: { + if (host) { + host.extractCurrentPageImagesFileUrl(selectedFolder) + } + if (host && host.focusRestoration) host.focusRestoration.restore() + } + onRejected: if (host && host.focusRestoration) host.focusRestoration.restore() + } + FileDialog { id: preflightReportDialog title: qsTr("Export Preflight Report") @@ -228,6 +254,12 @@ ApplicationWindow { profileSaveVersionField.text = host ? host.preflightProfileDraftVersion : "" preflightProfileSaveDialog.open() } + function onScreenshotRequested() { + screenshotDialog.open() + } + function onImageExtractionRequested() { + extractImagesDialog.open() + } } TextField { diff --git a/tools/ProductQuickAccessibilitySmoke/qml/ShellToolBar.qml b/tools/ProductQuickAccessibilitySmoke/qml/ShellToolBar.qml index bfad8d5ae..b8ca48281 100644 --- a/tools/ProductQuickAccessibilitySmoke/qml/ShellToolBar.qml +++ b/tools/ProductQuickAccessibilitySmoke/qml/ShellToolBar.qml @@ -157,6 +157,38 @@ ToolBar { value: !!root.host && root.host.activeTool === "hand" } } + ToolButton { + id: textToolButton + objectName: "textToolButton" + text: qsTr("Text") + checkable: true + activeFocusOnTab: true + onClicked: if (root.host) root.host.setActiveTool("text") + Accessible.role: Accessible.RadioButton + Accessible.name: qsTr("Text selection tool") + ButtonGroup.group: toolGroup + Binding { + target: textToolButton + property: "checked" + value: !!root.host && root.host.activeTool === "text" + } + } + ToolButton { + id: measureToolButton + objectName: "measureToolButton" + text: qsTr("Measure") + checkable: true + activeFocusOnTab: true + onClicked: if (root.host) root.host.setActiveTool("measure") + Accessible.role: Accessible.RadioButton + Accessible.name: qsTr("Measure tool") + ButtonGroup.group: toolGroup + Binding { + target: measureToolButton + property: "checked" + value: !!root.host && root.host.activeTool === "measure" + } + } ToolSeparator {} ToolButton { objectName: "zoomInButton" From bb79912d00ff16b7f66ebd671afad5d4239c3755 Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:29:07 -0700 Subject: [PATCH 05/12] feat(quick): display colour modes and soft-proof separations (#235, #237) Five actionColor* display modes become presentation-only filters applied in CanvasNodeBuilder::syncTiles over admitted surfaces, converted with Core's own PDFColorConvertor, marked as simulations in the fidelity indicator and never reachable as production evidence. Soft proof renders through a local LittleCMS proofing CMS behind its own surface identity (withSoftProofMarker), shows the profile identity, and a missing or unreadable output intent is a visible state. Plates expose Core's separations with visibility and isolation state that never moves the revision, and the ink-coverage readout measures named channels at a page point. --- LoopEditor/documentviewsession.cpp | 5 + LoopEditor/documentviewsession.h | 7 + LoopEditor/editorhost.cpp | 681 +++++++++++++++++- LoopEditor/editorhost.h | 150 +++- LoopEditor/qml/ProductionPreviewPane.qml | 235 +++++- .../sources/pagesurfacecoordinator.cpp | 25 +- .../sources/pagesurfacecoordinator.h | 17 + LoopLibInteraction/sources/pagesurfacekey.cpp | 31 + LoopLibInteraction/sources/pagesurfacekey.h | 19 + .../sources/pagesurfacerenderer.cpp | 128 ++++ LoopLibQuick/CMakeLists.txt | 2 + LoopLibQuick/sources/canvasdisplaycolor.cpp | 143 ++++ LoopLibQuick/sources/canvasdisplaycolor.h | 93 +++ LoopLibQuick/sources/canvasnodebuilder.cpp | 61 +- LoopLibQuick/sources/canvasnodebuilder.h | 20 + LoopLibQuick/sources/loopcanvasitem.cpp | 72 ++ LoopLibQuick/sources/loopcanvasitem.h | 28 + LoopLibQuick/sources/loopcanvasitemscene.cpp | 9 +- LoopLibQuick/sources/loopstatevisual.cpp | 34 +- LoopLibQuick/sources/loopstatevisual.h | 18 +- UnitTests/tst_documentfacadetest.cpp | 4 +- UnitTests/tst_editorhosttest.cpp | 376 +++++++++- UnitTests/tst_loopstatevisualtest.cpp | 35 +- UnitTests/tst_quickcanvastest.cpp | 177 ++++- docs/loop-shell-actions.json | 20 +- docs/shell-handler-coverage.json | 14 +- scripts/verify-command-catalog.py | 5 + .../qml/ProductionPreviewPane.qml | 235 +++++- 28 files changed, 2542 insertions(+), 102 deletions(-) create mode 100644 LoopLibQuick/sources/canvasdisplaycolor.cpp create mode 100644 LoopLibQuick/sources/canvasdisplaycolor.h diff --git a/LoopEditor/documentviewsession.cpp b/LoopEditor/documentviewsession.cpp index 3a8f0a012..c14cc95fa 100644 --- a/LoopEditor/documentviewsession.cpp +++ b/LoopEditor/documentviewsession.cpp @@ -253,6 +253,11 @@ void DocumentViewSession::setSurfaceRenderFeatures(pdf::PDFRenderer::Features fe m_surfaces->setRenderSettings(settings); } +void DocumentViewSession::setSoftProofProfile(const QString& proofProfileId) +{ + m_surfaces->setSoftProofProfile(proofProfileId); +} + void DocumentViewSession::setCacheLimit(qsizetype totalBytes) { const qsizetype normalized = pdf::PDFPageCacheBudget::total(totalBytes); diff --git a/LoopEditor/documentviewsession.h b/LoopEditor/documentviewsession.h index 5de1d1114..19f544b68 100644 --- a/LoopEditor/documentviewsession.h +++ b/LoopEditor/documentviewsession.h @@ -81,6 +81,13 @@ class DocumentViewSession final : public QObject void clearDocumentView(); void setSurfaceRenderFeatures(pdf::PDFRenderer::Features features); + /// The proofing profile identity in force for the interactive canvas (issue + /// #237). Empty disables proofing. The surfaces re-render as proofs through + /// Core's colour management; the identity joins the surface key, so a proof + /// can never be served from, or mistaken for, the fast or authoritative + /// render of the same page. + void setSoftProofProfile(const QString& proofProfileId); + quint64 passwordRequestId() const; bool answerPassword(quint64 requestId, QString password, bool accepted); diff --git a/LoopEditor/editorhost.cpp b/LoopEditor/editorhost.cpp index e07b1762d..27466fa71 100644 --- a/LoopEditor/editorhost.cpp +++ b/LoopEditor/editorhost.cpp @@ -54,6 +54,7 @@ #include "pdfsafefilewriter.h" #include "pdfblockingthreadguard.h" +#include "pdfcms.h" #include "pdfpage.h" #include "pdftransparencyrenderer.h" @@ -169,6 +170,15 @@ int rotationToDegrees(pdf::PageRotation rotation) return 0; } +/// Rasterization resolution of the ink-coverage point readout. It samples one +/// point, not a page: the value only has to be fine enough that the pixel under +/// the point is that point, and the job is charged for the pixels it renders. +constexpr int InkCoverageReadoutDpi = 150; + +/// Ceiling on the readout raster's longest edge. A huge page samples the same +/// point at a coarser resolution instead of rasterizing an unbounded image. +constexpr int InkCoverageMaxRasterPixels = 4096; + QString actionListStateToString(pdfinteraction::ActionListController::State state) { switch (state) @@ -418,6 +428,7 @@ EditorHost::EditorHost(QObject* parent) : finishRollbackJob(snapshot); finishPreflightJob(snapshot); finishActionListJob(snapshot); + finishInkCoverageJob(snapshot); refreshCanvasTrace(); }); } @@ -1257,10 +1268,28 @@ QString EditorHost::previewFidelityStateName() const { return QStringLiteral("unavailable"); } + // A display simulation outranks the render underneath it: the operator is + // looking at a transformed page, so the indicator may not report the render + // path's own fidelity as if that were what is on screen (issue #235 display + // half, issue #237). return pdfquick::tokens::classifyPreviewFidelityState(hasDocument(), stale, pageFidelityIsAuthoritative(), - pageFidelityIsExact()); + pageFidelityIsExact(), + displaySimulationName()); +} + +QString EditorHost::displaySimulationName() const +{ + if (softProofActive()) + { + return QStringLiteral("soft-proof"); + } + if (m_displayColor.isActive()) + { + return QStringLiteral("display-filter"); + } + return QString(); } QVariantMap EditorHost::previewFidelityVisual() const @@ -1332,6 +1361,20 @@ QString EditorHost::previewFidelitySummary() const "authoritative overprint render before any print claim.") .arg(pageFidelityReason()); } + if (state == QLatin1String("display-filter")) + { + return tr("Display colour simulation (%1): a display colour mode is filtering the canvas, so " + "these pixels are a presentation of the page and not a production render. They are " + "never recorded as approval evidence.") + .arg(displayColorModeName()); + } + if (state == QLatin1String("soft-proof")) + { + return tr("Soft-proof simulation of the output intent through colour management (profile %1). " + "It is a simulation of print, not the print's evidence, and it is never recorded as " + "approval.") + .arg(m_softProofProfile.name.isEmpty() ? m_softProofProfile.id : m_softProofProfile.name); + } return tr("Fidelity exact, origin fast-canvas: this page reports no overprint-sensitive content. " "The interactive preview still does not certify print-safe output."); } @@ -1870,6 +1913,607 @@ bool EditorHost::ensureAuthoritativePreview() return true; } +QString EditorHost::displayColorModeName() const +{ + return pdfquick::getCanvasDisplayColorModeName(m_displayColor.mode); +} + +bool EditorHost::displayColorModeActive() const +{ + return m_displayColor.isActive(); +} + +QColor EditorHost::displayColorCustomForeground() const +{ + return m_displayColor.customForeground; +} + +QColor EditorHost::displayColorCustomBackground() const +{ + return m_displayColor.customBackground; +} + +int EditorHost::displayColorBitonalThreshold() const +{ + return m_displayColor.bitonalThreshold; +} + +bool EditorHost::setDisplayColorMode(const QString& modeName) +{ + const std::optional mode = + pdfquick::canvasDisplayColorModeFromName(modeName); + if (!mode) + { + return false; + } + + m_displayColor.mode = *mode; + applyDisplayColorToCanvas(); + bumpPresentation(); + return true; +} + +bool EditorHost::setDisplayColorCustomColors(const QColor& foreground, const QColor& background) +{ + if (!foreground.isValid() || !background.isValid()) + { + return false; + } + + m_displayColor.customForeground = foreground; + m_displayColor.customBackground = background; + applyDisplayColorToCanvas(); + bumpPresentation(); + return true; +} + +bool EditorHost::setDisplayColorBitonalThreshold(int threshold) +{ + if (threshold < 1 || threshold > 254) + { + return false; + } + + m_displayColor.bitonalThreshold = threshold; + applyDisplayColorToCanvas(); + bumpPresentation(); + return true; +} + +void EditorHost::applyDisplayColorToCanvas() +{ + if (m_canvas) + { + m_canvas->setDisplayColorMode(pdfquick::getCanvasDisplayColorModeName(m_displayColor.mode)); + m_canvas->setDisplayColorCustomForeground(m_displayColor.customForeground); + m_canvas->setDisplayColorCustomBackground(m_displayColor.customBackground); + m_canvas->setDisplayColorBitonalThreshold(m_displayColor.bitonalThreshold); + } +} + +bool EditorHost::softProofActive() const +{ + return m_softProofEnabled && !m_softProofProfile.id.isEmpty(); +} + +QString EditorHost::softProofStateName() const +{ + return m_softProofStateName; +} + +QString EditorHost::softProofProfileName() const +{ + return m_softProofProfile.name; +} + +QString EditorHost::softProofProfileId() const +{ + return m_softProofProfile.id; +} + +QString EditorHost::softProofProfileTypeName() const +{ + return m_softProofProfile.typeName; +} + +QString EditorHost::softProofSummary() const +{ + if (!hasDocument()) + { + return tr("No document is open, so there is no output intent to simulate."); + } + if (softProofActive()) + { + return tr("Soft proof of the output intent through colour management. The profile is %1. " + "This is a simulation of print, not the print's evidence: it is never recorded " + "as approval.") + .arg(m_softProofProfile.name.isEmpty() ? m_softProofProfile.id : m_softProofProfile.name); + } + if (m_softProofStateName == QLatin1String("profile-missing")) + { + return tr("Soft proof is unavailable: this document declares no output-intent profile. " + "Nothing is simulated and the canvas keeps the unproofed render."); + } + if (m_softProofStateName == QLatin1String("profile-invalid")) + { + return tr("Soft proof is unavailable: the document's output-intent profile could not be " + "read. Nothing is simulated and the canvas keeps the unproofed render."); + } + return tr("Soft proof is off. The canvas shows the unproofed render."); +} + +std::optional EditorHost::resolveOutputIntentProfile(bool* documentDeclaresOutputIntent) const +{ + if (documentDeclaresOutputIntent) + { + *documentDeclaresOutputIntent = false; + } + + if (!hasDocument()) + { + return std::nullopt; + } + + pdf::PDFDocumentSession* session = m_session->context().tryGetSession(); + const pdf::PDFDocument* document = m_session->context().getDocument(); + if (!session || !document) + { + return std::nullopt; + } + + if (documentDeclaresOutputIntent) + { + *documentDeclaresOutputIntent = !document->getCatalog()->getOutputIntents().empty(); + } + + // Core resolves the output intent: PDFCMSManager decodes the profile stream + // under the session's operation budget and refuses one it cannot open, so an + // unreadable profile is absent here rather than guessed at. A budget failure + // is the same kind of answer -- the profile could not be read -- and is + // reported as one instead of escaping a presentation call. + try + { + pdf::PDFCMSManager cmsManager(nullptr); + cmsManager.setDocument(document, session->getProcessingBudget()); + + for (const pdf::PDFColorProfileIdentifier& identifier : cmsManager.getCMYKProfiles()) + { + if (!identifier.isOutputIntentProfile) + { + continue; + } + + SoftProofProfile profile; + profile.id = identifier.id; + profile.name = identifier.name; + profile.typeName = QStringLiteral("output-intent"); + return profile; + } + } + catch (const pdf::PDFException&) + { + return std::nullopt; + } + return std::nullopt; +} + +bool EditorHost::setSoftProofEnabled(bool enabled) +{ + if (!enabled) + { + const bool wasActive = softProofActive(); + m_softProofEnabled = false; + m_softProofStateName = QStringLiteral("off"); + m_softProofProfile = SoftProofProfile(); + if (m_session) + { + m_session->setSoftProofProfile(QString()); + } + if (wasActive) + { + bumpPresentation(); + } + return wasActive; + } + + if (!hasDocument()) + { + m_softProofEnabled = false; + m_softProofStateName = QStringLiteral("profile-missing"); + bumpPresentation(); + return false; + } + + bool declaresOutputIntent = false; + const std::optional profile = resolveOutputIntentProfile(&declaresOutputIntent); + if (!profile) + { + m_softProofEnabled = false; + m_softProofStateName = declaresOutputIntent ? QStringLiteral("profile-invalid") + : QStringLiteral("profile-missing"); + bumpPresentation(); + return false; + } + + m_softProofProfile = *profile; + m_softProofEnabled = true; + m_softProofStateName = QStringLiteral("simulating"); + m_session->setSoftProofProfile(m_softProofProfile.id); + bumpPresentation(); + return true; +} + +bool EditorHost::toggleSoftProof() +{ + if (softProofActive()) + { + setSoftProofEnabled(false); + return false; + } + return setSoftProofEnabled(true); +} + +QVariantMap EditorHost::plateRow(const QString& id) const +{ + for (const QVariant& entry : m_plates) + { + const QVariantMap row = entry.toMap(); + if (row.value(QStringLiteral("id")).toString() == id) + { + return row; + } + } + return QVariantMap(); +} + +QVariantList EditorHost::productionPlates() const +{ + return m_plates; +} + +QString EditorHost::plateIsolationSummary() const +{ + if (m_plates.isEmpty()) + { + return tr("No separations are known for this document."); + } + if (!m_isolatedPlateId.isEmpty()) + { + const QVariantMap isolated = plateRow(m_isolatedPlateId); + return tr("Isolated plate: %1. The other plates are hidden.") + .arg(isolated.value(QStringLiteral("displayName")).toString()); + } + + QStringList hidden; + for (const QVariant& entry : m_plates) + { + const QVariantMap row = entry.toMap(); + if (!row.value(QStringLiteral("visible")).toBool()) + { + hidden.append(row.value(QStringLiteral("displayName")).toString()); + } + } + if (hidden.isEmpty()) + { + return tr("Every plate is shown."); + } + return tr("Hidden plates: %1.").arg(hidden.join(QStringLiteral(", "))); +} + +bool EditorHost::setPlateVisible(const QString& plateId, bool visible) +{ + if (plateRow(plateId).isEmpty()) + { + return false; + } + + m_plateVisible.insert(plateId, visible); + if (!visible && m_isolatedPlateId == plateId) + { + m_isolatedPlateId.clear(); + } + reloadProductionPlates(); + bumpPresentation(); + return true; +} + +bool EditorHost::isolatePlate(const QString& plateId) +{ + if (plateRow(plateId).isEmpty()) + { + return false; + } + + m_isolatedPlateId = plateId; + for (const QVariant& entry : m_plates) + { + const QString id = entry.toMap().value(QStringLiteral("id")).toString(); + m_plateVisible.insert(id, id == plateId); + } + reloadProductionPlates(); + bumpPresentation(); + return true; +} + +bool EditorHost::showAllPlates() +{ + m_isolatedPlateId.clear(); + for (const QVariant& entry : m_plates) + { + m_plateVisible.insert(entry.toMap().value(QStringLiteral("id")).toString(), true); + } + reloadProductionPlates(); + bumpPresentation(); + return true; +} + +void EditorHost::reloadProductionPlates() +{ + m_plates.clear(); + + if (!hasDocument()) + { + return; + } + + pdf::PDFDocumentSession* session = m_session->context().tryGetSession(); + const pdf::PDFDocument* document = m_session->context().getDocument(); + if (!session || !document) + { + return; + } + + // The press separations Core resolves for this document: the four process + // plates the preview simulates plus the document's own spot inks. This is + // the same PDFInkMapper recipe PdfTool's ink tools and the authoritative + // renderer use, so the list names the inks the render path actually knows. + try + { + pdf::PDFCMSManager cmsManager(nullptr); + cmsManager.setDocument(document, session->getProcessingBudget()); + pdf::PDFInkMapper inkMapper(&cmsManager, document); + inkMapper.createSpotColors(true); + + for (const pdf::PDFInkMapper::ColorInfo& info : inkMapper.getSeparations(4, true)) + { + const QString id = QString::fromLatin1(info.name); + QVariantMap row; + row.insert(QStringLiteral("id"), id); + row.insert(QStringLiteral("displayName"), info.textName.isEmpty() ? id : info.textName); + row.insert(QStringLiteral("kind"), info.isSpot ? QStringLiteral("spot") : QStringLiteral("process")); + row.insert(QStringLiteral("spotColorName"), info.isSpot ? id : QString()); + row.insert(QStringLiteral("color"), info.color.name()); + row.insert(QStringLiteral("visible"), m_plateVisible.value(id, true)); + row.insert(QStringLiteral("isolated"), m_isolatedPlateId == id); + m_plates.append(row); + } + } + catch (const pdf::PDFException&) + { + // A document whose inks could not be enumerated says so through the + // empty list and its summary rather than presenting an invented one. + m_plates.clear(); + } +} + +bool EditorHost::requestInkCoverageAtPoint(qreal pageX, qreal pageY) +{ + if (!hasDocument() || !m_session->revisionSource()) + { + return false; + } + + const int pageIndex = currentPage(); + const pdf::PDFDocumentPointer document = m_session->context().getDocumentPointer(); + if (!document || pageIndex < 0) + { + return false; + } + + const QString jobId = QUuid::createUuid().toString(QUuid::WithoutBraces); + pdf::PDFJobSpec spec; + spec.jobId = jobId; + spec.kind = pdf::PDFJobKind::Other; + spec.priority = pdf::PDFJobPriority::Operator; + spec.documentKey = m_session->revisionSource()->documentKey(); + spec.documentRevision = m_session->facade().currentRevision().toString(); + spec.operationId = QStringLiteral("ink-coverage.point"); + spec.staleResultPolicy = pdf::PDFJobStaleResultPolicy::Discard; + + auto outcome = std::make_shared(); + outcome->pageIndex = pageIndex; + m_inkCoverageOutcomes.insert(jobId, outcome); + + m_session->scheduler().submit(spec, [document, outcome, pageIndex, pageX, pageY](pdf::PDFJobContext& context) + { + if (context.isCancellationRequested()) + { + return; + } + + const pdf::PDFPage* page = document->getCatalog()->getPage(static_cast(pageIndex)); + if (!page) + { + outcome->reason = QStringLiteral("ink-coverage/no-such-page"); + return; + } + + // The readout is rendered through Core's separation-accurate path, so the + // coverage it reports is the coverage of the separated inks rather than + // of the display raster. + pdf::PDFCMSManager cmsManager(nullptr); + cmsManager.setDocument(document.data(), nullptr); + pdf::PDFInkMapper inkMapper(&cmsManager, document.data()); + inkMapper.createSpotColors(true); + + pdf::PDFFontCache fontCache(pdf::DEFAULT_FONT_CACHE_LIMIT, pdf::DEFAULT_REALIZED_FONT_CACHE_LIMIT); + pdf::PDFOptionalContentActivity optionalContent(document.data(), pdf::OCUsage::Print, nullptr); + pdf::PDFModifiedDocument modifiedDocument(document.data(), &optionalContent); + fontCache.setDocument(modifiedDocument); + fontCache.setCacheShrinkEnabled(nullptr, false); + + pdf::PDFTransparencyRendererSettings rendererSettings; + rendererSettings.flags.setFlag(pdf::PDFTransparencyRendererSettings::SeparationSimulation, true); + rendererSettings.renderPolicy = pdf::PDFRenderPolicy::forSeparationPreview(); + + // The readout raster is chosen for the point query, not for display: it + // only has to be fine enough that one pixel is a point sample, and the + // job is charged for every pixel it rasterizes. + const QRectF cropBox = page->getCropBox(); + if (!cropBox.isValid() || cropBox.width() <= 0.0 || cropBox.height() <= 0.0) + { + outcome->reason = QStringLiteral("ink-coverage/page-has-no-box"); + return; + } + + const qreal scale = static_cast(InkCoverageReadoutDpi) / 72.0; + QSize pixelSize(qRound(cropBox.width() * scale), qRound(cropBox.height() * scale)); + pixelSize = pixelSize.boundedTo(QSize(InkCoverageMaxRasterPixels, InkCoverageMaxRasterPixels)); + if (pixelSize.isEmpty()) + { + outcome->reason = QStringLiteral("ink-coverage/raster-too-small"); + return; + } + + context.processingBudget().chargeRenderPixels(static_cast(pixelSize.width()) * static_cast(pixelSize.height()), + QStringLiteral("ink coverage readout")); + + const QRectF deviceRect(QPointF(0.0, 0.0), QSizeF(pixelSize)); + const QTransform pagePointToDevice = pdf::PDFRenderer::createPagePointToDevicePointMatrix(page, deviceRect, pdf::PageRotation::None); + const QPointF devicePoint = pagePointToDevice.map(QPointF(pageX, pageY)); + const int pixelX = qFloor(devicePoint.x()); + const int pixelY = qFloor(devicePoint.y()); + if (pixelX < 0 || pixelY < 0 || pixelX >= pixelSize.width() || pixelY >= pixelSize.height()) + { + outcome->reason = QStringLiteral("ink-coverage/point-outside-page"); + return; + } + + pdf::PDFTransparencyRenderer renderer(page, + document.data(), + &fontCache, + cmsManager.getCurrentCMS().data(), + &optionalContent, + &inkMapper, + rendererSettings, + pagePointToDevice); + renderer.setOperationControl(context.operationControl()); + renderer.beginPaint(pixelSize); + renderer.processContents(); + const pdf::PDFFloatBitmap& separated = renderer.endPaint(); + + if (context.isCancellationRequested()) + { + return; + } + + const pdf::PDFConstColorBuffer channels = separated.getPixel(static_cast(pixelX), static_cast(pixelY)); + const pdf::PDFPixelFormat format = separated.getPixelFormat(); + + QVariantList channelRows; + const std::vector separations = inkMapper.getSeparations(4, true); + const int channelCount = format.getColorChannelCount(); + for (int channel = 0; channel < channelCount; ++channel) + { + QVariantMap row; + const size_t separationIndex = static_cast(channel); + if (separationIndex < separations.size()) + { + row.insert(QStringLiteral("name"), QString::fromLatin1(separations[separationIndex].name)); + row.insert(QStringLiteral("displayName"), separations[separationIndex].textName); + row.insert(QStringLiteral("isSpot"), separations[separationIndex].isSpot); + } + else + { + row.insert(QStringLiteral("name"), QStringLiteral("channel-%1").arg(channel)); + row.insert(QStringLiteral("displayName"), QStringLiteral("Channel %1").arg(channel)); + row.insert(QStringLiteral("isSpot"), false); + } + row.insert(QStringLiteral("coverage"), double(channels[static_cast(channel)])); + channelRows.append(row); + } + + outcome->ok = true; + outcome->readout.insert(QStringLiteral("available"), true); + outcome->readout.insert(QStringLiteral("pageIndex"), pageIndex); + outcome->readout.insert(QStringLiteral("pageX"), pageX); + outcome->readout.insert(QStringLiteral("pageY"), pageY); + outcome->readout.insert(QStringLiteral("totalInkCoverage"), double(separated.getPixelInkCoverage(static_cast(pixelX), static_cast(pixelY)))); + outcome->readout.insert(QStringLiteral("channelCount"), channelCount); + outcome->readout.insert(QStringLiteral("channels"), channelRows); + outcome->readout.insert(QStringLiteral("rasterSize"), pixelSize); + outcome->readout.insert(QStringLiteral("reason"), QString()); }); + + return true; +} + +QVariantMap EditorHost::inkCoverageReadout() const +{ + return m_inkCoverageReadout; +} + +qreal EditorHost::currentPageCentreX() const +{ + return currentPageCentre().x(); +} + +qreal EditorHost::currentPageCentreY() const +{ + return currentPageCentre().y(); +} + +QPointF EditorHost::currentPageCentre() const +{ + if (!hasDocument()) + { + return QPointF(); + } + + const pdf::PDFDocument* document = m_session->context().getDocument(); + const int pageIndex = currentPage(); + if (!document || pageIndex < 0 || pageIndex >= static_cast(document->getCatalog()->getPageCount())) + { + return QPointF(); + } + + const pdf::PDFPage* page = document->getCatalog()->getPage(static_cast(pageIndex)); + if (!page) + { + return QPointF(); + } + + const QRectF cropBox = page->getCropBox(); + return cropBox.isValid() ? cropBox.center() : QPointF(); +} + +void EditorHost::finishInkCoverageJob(const pdf::PDFJobSnapshot& snapshot) +{ + const auto outcome = m_inkCoverageOutcomes.take(snapshot.jobId); + if (!outcome) + { + return; + } + + if (!outcome->ok) + { + // A readout that could not be produced is a visible state, never a zero: + // "0% ink" and "not measured" are different facts. + QVariantMap readout; + readout.insert(QStringLiteral("available"), false); + readout.insert(QStringLiteral("pageIndex"), outcome->pageIndex); + readout.insert(QStringLiteral("reason"), outcome->reason.isEmpty() + ? QStringLiteral("ink-coverage/not-measured") + : outcome->reason); + readout.insert(QStringLiteral("channels"), QVariantList()); + m_inkCoverageReadout = readout; + } + else + { + m_inkCoverageReadout = outcome->readout; + } + bumpPresentation(); +} + void EditorHost::selectFinding(const QString& findingId) { if (!m_session->revisionSource() || findingId.isEmpty()) @@ -3811,6 +4455,24 @@ void EditorHost::registerFeatureHandlers() bind(QStringLiteral("actionExtractImage"), [this] { Q_EMIT imageExtractionRequested(); }); + // Display colour modes (#235 display half). Presentation-only: each filters + // the pixels the canvas already holds and asks for no render. Invoking the + // mode that is already in force returns the display to normal, which is the + // toggle a checkable action used to provide -- there is no sixth action for + // "normal", and a mode with no way back is a trap. + for (const pdfquick::CanvasDisplayColorMode mode : { pdfquick::CanvasDisplayColorMode::Invert, + pdfquick::CanvasDisplayColorMode::Grayscale, + pdfquick::CanvasDisplayColorMode::HighContrast, + pdfquick::CanvasDisplayColorMode::Bitonal, + pdfquick::CanvasDisplayColorMode::Custom }) + { + const QString actionId = pdfquick::getCanvasDisplayColorModeActionId(mode); + bind(actionId, [this, mode] + { setDisplayColorMode(m_displayColor.mode == mode + ? QStringLiteral("normal") + : pdfquick::getCanvasDisplayColorModeName(mode)); }); + } + pdfinteraction::CommandCatalog::Handler move; move.invoke = [this](pdfinteraction::CommandInvocationId invocation, const QVariantMap& parameters) { @@ -3831,7 +4493,10 @@ void EditorHost::refreshFeatureAvailability() QHash availability; for (const QString& id : { QStringLiteral("actionPageLayoutContinuous"), QStringLiteral("actionPageLayoutSinglePage"), QStringLiteral("actionPageLayoutTwoColumns"), QStringLiteral("actionPageLayoutTwoPages"), - QStringLiteral("actionFind"), QStringLiteral("actionProperties"), MoveSelectionCommandId }) + QStringLiteral("actionFind"), QStringLiteral("actionProperties"), MoveSelectionCommandId, + QStringLiteral("actionColorInvert"), QStringLiteral("actionColorGrayscale"), + QStringLiteral("actionColorHighContrast"), QStringLiteral("actionColorBitonal"), + QStringLiteral("actionColorCustom") }) { availability.insert(id, ready); } @@ -3900,6 +4565,7 @@ void EditorHost::bumpPresentation() { m_canvas->setHighContrast(highContrast()); } + applyDisplayColorToCanvas(); Q_EMIT presentationChanged(); } @@ -3918,6 +4584,7 @@ void EditorHost::onDocumentReady() syncDocumentLifecycle(); m_searchRow = -1; refreshHitTestSources(); + reloadProductionPlates(); m_documentBound = true; bindCanvas(); updateCanvasAccessibilitySummary(); @@ -3966,6 +4633,16 @@ void EditorHost::onDocumentGone() m_searchRow = -1; m_preview.clear(); m_production.clear(); + // Proofing and the plate list describe the document that just closed, so + // neither may outlive it: a soft proof of a document nobody has open would + // keep the canvas on a simulation with no revision behind it. + m_softProofEnabled = false; + m_softProofStateName = QStringLiteral("off"); + m_softProofProfile = SoftProofProfile(); + m_plates.clear(); + m_plateVisible.clear(); + m_isolatedPlateId.clear(); + m_inkCoverageReadout = QVariantMap(); m_session->hitTest()->clearSources(); m_documentBound = false; m_preflightCertificateStateName = QStringLiteral("not-certified"); diff --git a/LoopEditor/editorhost.h b/LoopEditor/editorhost.h index 492803a24..04ee5ddb1 100644 --- a/LoopEditor/editorhost.h +++ b/LoopEditor/editorhost.h @@ -48,6 +48,8 @@ #include "documentviewsession.h" #include "quickdocumentmodel.h" +#include "canvasdisplaycolor.h" + #include "pdfdocumentcontext.h" #include "pdfjobscheduler.h" #include "pdfoperationhistory.h" @@ -63,6 +65,7 @@ #include #include +#include namespace pdfinteraction { @@ -173,6 +176,29 @@ class EditorHost final : public QObject Q_PROPERTY(bool previewRequiresAuthoritative READ previewRequiresAuthoritative NOTIFY presentationChanged) Q_PROPERTY(QVariantMap compareReview READ compareReview NOTIFY presentationChanged) + // ---- Display colour modes (#235 display half) ----------------------------------- + // Presentation-only display modes. They filter the pixels the canvas already + // holds; they ask for no render, they join no surface identity, and the + // fidelity indicator reports them as a simulation. + Q_PROPERTY(QString displayColorModeName READ displayColorModeName NOTIFY presentationChanged) + Q_PROPERTY(bool displayColorModeActive READ displayColorModeActive NOTIFY presentationChanged) + Q_PROPERTY(QColor displayColorCustomForeground READ displayColorCustomForeground NOTIFY presentationChanged) + Q_PROPERTY(QColor displayColorCustomBackground READ displayColorCustomBackground NOTIFY presentationChanged) + Q_PROPERTY(int displayColorBitonalThreshold READ displayColorBitonalThreshold NOTIFY presentationChanged) + + // ---- Soft proof, plates and ink coverage (#237) --------------------------------- + Q_PROPERTY(bool softProofActive READ softProofActive NOTIFY presentationChanged) + Q_PROPERTY(QString softProofStateName READ softProofStateName NOTIFY presentationChanged) + Q_PROPERTY(QString softProofProfileName READ softProofProfileName NOTIFY presentationChanged) + Q_PROPERTY(QString softProofProfileId READ softProofProfileId NOTIFY presentationChanged) + Q_PROPERTY(QString softProofProfileTypeName READ softProofProfileTypeName NOTIFY presentationChanged) + Q_PROPERTY(QString softProofSummary READ softProofSummary NOTIFY presentationChanged) + Q_PROPERTY(QVariantList productionPlates READ productionPlates NOTIFY presentationChanged) + Q_PROPERTY(QString plateIsolationSummary READ plateIsolationSummary NOTIFY presentationChanged) + Q_PROPERTY(QVariantMap inkCoverageReadout READ inkCoverageReadout NOTIFY presentationChanged) + Q_PROPERTY(qreal currentPageCentreX READ currentPageCentreX NOTIFY presentationChanged) + Q_PROPERTY(qreal currentPageCentreY READ currentPageCentreY NOTIFY presentationChanged) + public: enum LoopWorkspace { @@ -309,13 +335,15 @@ class EditorHost final : public QObject /// Render fidelity and origin of the interactive preview (#28), the same fact on the /// ordinary canvas and in the Production Preview. `previewFidelityStateName` is one of - /// `unavailable`, `stale`, `exact`, `approximate` or `authoritative`, derived by - /// `pdfquick::tokens::classifyPreviewFidelityState()` from the preview state and the - /// current page's render diagnostics; `previewFidelityOriginName` names the render path - /// (`none`, `fast-canvas`, `output-preview`). `previewRequiresAuthoritative` is true for - /// the overprint-sensitive fast render (`approximate`), whose pixels are not proof of - /// print-safe output. The interactive preview never certifies publication safety: only - /// `authoritative` names the output-preview origin, and no state reaches a pass. + /// `unavailable`, `stale`, `exact`, `approximate`, `authoritative`, `display-filter` or + /// `soft-proof`, derived by `pdfquick::tokens::classifyPreviewFidelityState()` from the + /// preview state, the display simulation in force and the current page's render + /// diagnostics; `previewFidelityOriginName` names the render path (`none`, `fast-canvas`, + /// `output-preview`, `display-filter`, `soft-proof`). `previewRequiresAuthoritative` is + /// true for the overprint-sensitive fast render (`approximate`), whose pixels are not + /// proof of print-safe output. The interactive preview never certifies publication + /// safety: only `authoritative` names the output-preview origin, and no state reaches a + /// pass. QString previewFidelityStateName() const; QVariantMap previewFidelityVisual() const; QColor previewFidelityColor() const; @@ -323,6 +351,62 @@ class EditorHost final : public QObject QString previewFidelitySummary() const; bool previewRequiresAuthoritative() const; + // ---- Display colour modes (#235 display half) ----------------------------------- + /// The display colour mode in force, by vocabulary name, and the settings the + /// custom mode reads. A display mode is a presentation filter: it changes what + /// the canvas shows, never what the document is, and it is reported as a + /// simulation by the fidelity indicator. + QString displayColorModeName() const; + bool displayColorModeActive() const; + QColor displayColorCustomForeground() const; + QColor displayColorCustomBackground() const; + int displayColorBitonalThreshold() const; + /// Parses the mode name once at this boundary. An unknown name is refused and + /// changes nothing. + Q_INVOKABLE bool setDisplayColorMode(const QString& modeName); + Q_INVOKABLE bool setDisplayColorCustomColors(const QColor& foreground, const QColor& background); + Q_INVOKABLE bool setDisplayColorBitonalThreshold(int threshold); + + // ---- Soft proof (#237) ----------------------------------------------------------- + /// True while the canvas is showing the proofing simulation. + bool softProofActive() const; + /// `off`, `simulating`, `profile-missing` or `profile-invalid`. A document + /// whose output-intent profile is absent or unreadable is a visible state: the + /// proof is refused with the reason, never silently rendered as sRGB. + QString softProofStateName() const; + QString softProofProfileName() const; + QString softProofProfileId() const; + QString softProofProfileTypeName() const; + QString softProofSummary() const; + /// Enables or disables the proofing simulation. Enabling resolves the + /// document's output-intent profile through Core's colour management; a + /// document without a usable one returns false and reports `profile-missing` + /// or `profile-invalid`. + Q_INVOKABLE bool setSoftProofEnabled(bool enabled); + Q_INVOKABLE bool toggleSoftProof(); + + // ---- Plates and ink coverage (#237) ---------------------------------------------- + /// The separations Core resolves for the open document: the process plates and + /// the document's spot plates, each with its visibility and whether it is the + /// isolated plate. Presentation state: hiding or isolating a plate changes what + /// the preview shows, never the document. + QVariantList productionPlates() const; + QString plateIsolationSummary() const; + Q_INVOKABLE bool setPlateVisible(const QString& plateId, bool visible); + /// Shows exactly one plate and hides the others. An unknown plate id is refused. + Q_INVOKABLE bool isolatePlate(const QString& plateId); + Q_INVOKABLE bool showAllPlates(); + /// Renders the current page through Core's separation-accurate path and reports + /// the ink coverage at one page-space point. Asynchronous: the readout lands in + /// inkCoverageReadout() when the job finishes. + Q_INVOKABLE bool requestInkCoverageAtPoint(qreal pageX, qreal pageY); + QVariantMap inkCoverageReadout() const; + /// The centre of the current page's crop box in page space (PDF user space, the + /// same space requestInkCoverageAtPoint takes). The host computes it so a + /// surface can offer a point readout without deriving document geometry. + qreal currentPageCentreX() const; + qreal currentPageCentreY() const; + /// Read-only composition of the Core comparison facts for the Compare workspace: /// the before/after artifact identities, the technical finding delta, the attributes /// the comparison preserved, and the risk it left unresolved. Every field is a @@ -566,6 +650,29 @@ class EditorHost final : public QObject /// The visible canvas region as one image, or a null image when no admitted /// tile intersects it or the snapshot is stale. QImage composeVisibleCanvasImage() const; + // ---- Display colour modes, soft proof and plates (#235 display half, #237) ------- + void applyDisplayColorToCanvas(); + /// The simulation the fidelity indicator must name, or empty when the display + /// shows the render path's own pixels: `soft-proof` or `display-filter`. + QString displaySimulationName() const; + struct SoftProofProfile + { + QString id; + QString name; + QString typeName; + }; + /// Resolves the document's output-intent profile through Core's colour + /// management. `documentDeclaresOutputIntent` distinguishes "the document + /// names no output intent" from "the one it names could not be read", which + /// are two different visible states. Nothing is resolved without a document. + std::optional resolveOutputIntentProfile(bool* documentDeclaresOutputIntent) const; + /// Caches the separations Core resolves for the open document, keyed by + /// colorant name, keeping the operator's visibility state for plates that + /// are still present. + void reloadProductionPlates(); + QVariantMap plateRow(const QString& id) const; + QPointF currentPageCentre() const; + void finishInkCoverageJob(const pdf::PDFJobSnapshot& snapshot); /// Operator review of the planned correction (#586). `fixPlanIsCurrent()` is the only /// authority on whether the reviewed plan still describes the open revision. @@ -610,6 +717,35 @@ class EditorHost final : public QObject QString retentionError; }; QHash> m_rollbackOutcomes; + + /// Display colour mode in force (issue #235 display half). Presentation state: + /// pushed to the canvas item, never to a render request. + pdfquick::CanvasDisplayColorSettings m_displayColor; + + /// Soft proof state (issue #237). The profile is the document's output-intent + /// profile Core resolved; `m_softProofStateName` is `off`, `simulating`, + /// `profile-missing` or `profile-invalid`. + bool m_softProofEnabled = false; + QString m_softProofStateName = QStringLiteral("off"); + SoftProofProfile m_softProofProfile; + + /// Separations Core resolved for the open document, and the operator's own + /// presentation state over them. Visibility is keyed by colorant name so a + /// plate that is still present keeps its state across a reload. + QVariantList m_plates; + QHash m_plateVisible; + QString m_isolatedPlateId; + + /// The last ink-coverage readout, and the worker outcomes still in flight. + QVariantMap m_inkCoverageReadout; + struct InkCoverageOutcome + { + bool ok = false; + QString reason; + int pageIndex = -1; + QVariantMap readout; + }; + QHash> m_inkCoverageOutcomes; struct PreflightProfileChoice { QString id; diff --git a/LoopEditor/qml/ProductionPreviewPane.qml b/LoopEditor/qml/ProductionPreviewPane.qml index 58e7fd2e4..9c9af0178 100644 --- a/LoopEditor/qml/ProductionPreviewPane.qml +++ b/LoopEditor/qml/ProductionPreviewPane.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Controls +import QtQuick.Dialogs import QtQuick.Layouts // Production Preview workspace (#586): the identity-bound before/after preview. @@ -138,39 +139,227 @@ Pane { Accessible.description: qsTr("Re-renders the current page only; the document stays open.") } } + + Label { + objectName: "productionPreviewSoftProofState" + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: !root.host + ? "" + : qsTr("Soft proof %1: %2") + .arg(root.host.softProofStateName) + .arg(root.host.softProofSummary()) + Accessible.name: qsTr("Soft proof state") + Accessible.description: text + } + + Label { + objectName: "productionPreviewSoftProofProfile" + Layout.fillWidth: true + wrapMode: Text.WordWrap + visible: root.host !== null && root.host.softProofProfileId.length > 0 + text: root.host + ? qsTr("Output-intent profile %1 (identifier %2, type %3).") + .arg(root.host.softProofProfileName) + .arg(root.host.softProofProfileId) + .arg(root.host.softProofProfileTypeName) + : "" + Accessible.name: qsTr("Soft-proof profile identity") + Accessible.description: text + } + + RowLayout { + Layout.fillWidth: true + + Button { + objectName: "productionPreviewSoftProofToggle" + text: root.host && root.host.softProofActive + ? qsTr("Stop the soft proof") + : qsTr("Simulate the output intent (soft proof)") + enabled: root.host && root.host.hasDocument + onClicked: if (root.host) root.host.toggleSoftProof() + Accessible.name: qsTr("Toggle the soft-proof simulation of the output intent") + Accessible.description: qsTr("Renders the canvas through Core's colour management under proofing settings. A simulation of print, never recorded as approval.") + } + } } } + GroupBox { + Layout.fillWidth: true + title: qsTr("Display colour") + Accessible.name: qsTr("Display colour modes") + + ColumnLayout { + anchors.fill: parent + spacing: 4 + + ComboBox { + objectName: "productionPreviewDisplayColorMode" + Layout.fillWidth: true + enabled: root.host !== null + model: [ "normal", "invert", "grayscale", "high-contrast", "bitonal", "custom" ] + currentIndex: { + if (!root.host) + return 0 + const index = model.indexOf(root.host.displayColorModeName) + return index < 0 ? 0 : index + } + onActivated: if (root.host) root.host.setDisplayColorMode(model[currentIndex]) + Accessible.name: qsTr("Display colour mode") + Accessible.description: qsTr("Presentation only: filters the canvas pixels and asks for no render.") + } + + Label { + objectName: "productionPreviewDisplayColorState" + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: !root.host || !root.host.displayColorModeActive + ? qsTr("No display colour mode is active.") + : qsTr("Display colour simulation (%1): presentation only, never a production render.") + .arg(root.host.displayColorModeName) + Accessible.name: qsTr("Display colour state") + Accessible.description: text + } + + RowLayout { + Layout.fillWidth: true + + Button { + objectName: "productionPreviewCustomForeground" + text: qsTr("Custom foreground") + enabled: root.host !== null + onClicked: customForegroundDialog.open() + Accessible.name: qsTr("Choose the custom mode's foreground colour") + } + + Button { + objectName: "productionPreviewCustomBackground" + text: qsTr("Custom background") + enabled: root.host !== null + onClicked: customBackgroundDialog.open() + Accessible.name: qsTr("Choose the custom mode's background colour") + } + } + } + } + + ColorDialog { + id: customForegroundDialog + title: qsTr("Custom mode foreground") + selectedColor: root.host ? root.host.displayColorCustomForeground : "black" + onAccepted: if (root.host) root.host.setDisplayColorCustomColors(selectedColor, root.host.displayColorCustomBackground) + } + + ColorDialog { + id: customBackgroundDialog + title: qsTr("Custom mode background") + selectedColor: root.host ? root.host.displayColorCustomBackground : "white" + onAccepted: if (root.host) root.host.setDisplayColorCustomColors(root.host.displayColorCustomForeground, selectedColor) + } + GroupBox { Layout.fillWidth: true Layout.fillHeight: true title: qsTr("Plates and separations") Accessible.name: qsTr("Plates and separations") - ListView { - id: platesView - objectName: "productionPreviewPlatesView" + ColumnLayout { anchors.fill: parent - clip: true - focus: visible - activeFocusOnTab: true - model: root.production - - Accessible.role: Accessible.List - Accessible.name: qsTr("Plates") - Accessible.description: qsTr("Inks and plates for the current page.") - - delegate: ItemDelegate { - id: plateRow - width: platesView.width - text: plateRow.displayName - Accessible.role: Accessible.ListItem - Accessible.name: plateRow.displayName - Accessible.description: qsTr("Kind %1. Spot colour %2. Overprint %3. Prints %4.") - .arg(plateRow.kind) - .arg(plateRow.spotColorName) - .arg(plateRow.overprint ? qsTr("yes") : qsTr("no")) - .arg(plateRow.shouldPrint ? qsTr("yes") : qsTr("no")) + spacing: 4 + + Label { + objectName: "productionPreviewPlateIsolation" + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: root.host ? root.host.plateIsolationSummary : "" + Accessible.name: qsTr("Plate isolation state") + Accessible.description: text + } + + ListView { + id: platesView + objectName: "productionPreviewPlatesView" + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + focus: visible + activeFocusOnTab: true + model: root.host ? root.host.productionPlates : [] + + Accessible.role: Accessible.List + Accessible.name: qsTr("Plates") + Accessible.description: qsTr("Inks and plates for the current page. Each plate can be shown alone or hidden.") + + delegate: ItemDelegate { + id: plateRow + width: platesView.width + text: plateRow.modelData ? plateRow.modelData.displayName : "" + Accessible.role: Accessible.ListItem + Accessible.name: plateRow.modelData ? plateRow.modelData.displayName : "" + Accessible.description: qsTr("Kind %1. Spot colour %2. Shown %3. Isolated %4.") + .arg(plateRow.modelData ? plateRow.modelData.kind : "") + .arg(plateRow.modelData ? plateRow.modelData.spotColorName : "") + .arg(plateRow.modelData && plateRow.modelData.visible ? qsTr("yes") : qsTr("no")) + .arg(plateRow.modelData && plateRow.modelData.isolated ? qsTr("yes") : qsTr("no")) + + CheckBox { + objectName: "productionPreviewPlateVisibleCheck" + anchors.verticalCenter: parent.verticalCenter + checked: plateRow.modelData ? plateRow.modelData.visible : false + onToggled: if (root.host && plateRow.modelData) root.host.setPlateVisible(plateRow.modelData.id, checked) + Accessible.name: qsTr("Show plate %1").arg(plateRow.modelData ? plateRow.modelData.displayName : "") + } + + Button { + objectName: "productionPreviewPlateIsolate" + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: qsTr("Isolate") + onClicked: if (root.host && plateRow.modelData) root.host.isolatePlate(plateRow.modelData.id) + Accessible.name: qsTr("Show only plate %1").arg(plateRow.modelData ? plateRow.modelData.displayName : "") + } + } + } + + RowLayout { + Layout.fillWidth: true + + Button { + objectName: "productionPreviewShowAllPlates" + text: qsTr("Show all plates") + enabled: root.host !== null + onClicked: if (root.host) root.host.showAllPlates() + Accessible.name: qsTr("Show every plate") + } + + Button { + objectName: "productionPreviewInkCoverage" + text: qsTr("Measure ink at the page centre") + enabled: root.host && root.host.hasDocument + onClicked: if (root.host) root.host.requestInkCoverageAtPoint(root.host.currentPageCentreX, root.host.currentPageCentreY) + Accessible.name: qsTr("Measure ink coverage at the page centre") + Accessible.description: qsTr("Renders the current page through Core's separation-accurate path and reports the ink coverage at one point.") + } + } + + Label { + objectName: "productionPreviewInkCoverageReadout" + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: { + if (!root.host || !root.host.inkCoverageReadout || !root.host.inkCoverageReadout.available) + return root.host && root.host.inkCoverageReadout && root.host.inkCoverageReadout.reason + ? qsTr("Ink coverage is not measured: %1.").arg(root.host.inkCoverageReadout.reason) + : qsTr("Ink coverage has not been measured at a point.") + return qsTr("Ink coverage at (%1, %2) pt: %3 total across %4 plate(s).") + .arg(Number(root.host.inkCoverageReadout.pageX).toFixed(1)) + .arg(Number(root.host.inkCoverageReadout.pageY).toFixed(1)) + .arg(Number(root.host.inkCoverageReadout.totalInkCoverage).toFixed(3)) + .arg(root.host.inkCoverageReadout.channelCount) + } + Accessible.name: qsTr("Ink coverage readout") + Accessible.description: text } } } diff --git a/LoopLibInteraction/sources/pagesurfacecoordinator.cpp b/LoopLibInteraction/sources/pagesurfacecoordinator.cpp index 336afa867..783eb9c47 100644 --- a/LoopLibInteraction/sources/pagesurfacecoordinator.cpp +++ b/LoopLibInteraction/sources/pagesurfacecoordinator.cpp @@ -245,9 +245,11 @@ std::optional PageSurfaceCoordinator::keyForPage(int pageIndex) const qreal devicePixelRatio = m_viewport->devicePixelRatio(); const QSize targetPixelSize(qRound(placedRect.width() * devicePixelRatio), qRound(placedRect.height() * devicePixelRatio)); - const QString colorOutputIdentity = m_authoritativePages.contains(pageIndex) - ? withAuthoritativeOverprintMarker(m_settings.colorOutputIdentity) - : m_settings.colorOutputIdentity; + const QString colorOutputIdentity = m_softProofProfile.isEmpty() + ? (m_authoritativePages.contains(pageIndex) + ? withAuthoritativeOverprintMarker(m_settings.colorOutputIdentity) + : m_settings.colorOutputIdentity) + : withSoftProofMarker(m_settings.colorOutputIdentity, m_softProofProfile); return makePageSurfaceKey(revision, pageIndex, m_viewport->rotation(), m_settings.features, colorOutputIdentity, m_viewport->zoom(), targetPixelSize, devicePixelRatio); } @@ -276,6 +278,23 @@ void PageSurfaceCoordinator::setPageAuthoritativeOverprint(int pageIndex, bool e requestSurfaces(); } +void PageSurfaceCoordinator::setSoftProofProfile(const QString& proofProfileId) +{ + if (m_softProofProfile == proofProfileId) + { + return; + } + + m_softProofProfile = proofProfileId; + + // Every page's key changed, so nothing cached under the previous output + // identity may stand in -- a proofed surface is a different identity from + // the fast render and from the authoritative one. rebuildSnapshot() first so + // the display never shows an unproofed surface while the proof is pending. + rebuildSnapshot(); + requestSurfaces(); +} + std::optional PageSurfaceCoordinator::diagnosticsForPage(int pageIndex) const { const std::optional wanted = keyForPage(pageIndex); diff --git a/LoopLibInteraction/sources/pagesurfacecoordinator.h b/LoopLibInteraction/sources/pagesurfacecoordinator.h index 0b9983bcb..0b8374b5e 100644 --- a/LoopLibInteraction/sources/pagesurfacecoordinator.h +++ b/LoopLibInteraction/sources/pagesurfacecoordinator.h @@ -151,6 +151,19 @@ class PageSurfaceCoordinator final : public QObject void setPageAuthoritativeOverprint(int pageIndex, bool enabled); bool isPageAuthoritativeOverprint(int pageIndex) const { return m_authoritativePages.contains(pageIndex); } + /// Requests the soft-proof simulation of the output intent for every page + /// (issue #237). The proofing profile identity joins the colour-managed + /// output identity (see withSoftProofMarker), so a proofed surface gets its + /// own cache slot and can neither be served from nor stand in for the fast + /// or authoritative render of the same page. Empty disables proofing. + /// + /// This is a render request, not a display filter: the pixels are produced + /// by Core's colour management under proofing settings, and the simulation + /// identity travels with the key. + void setSoftProofProfile(const QString& proofProfileId); + QString softProofProfile() const { return m_softProofProfile; } + bool isSoftProofing() const { return !m_softProofProfile.isEmpty(); } + /// Diagnostics for the surface currently admitted for \p pageIndex, if any. /// Reflects whichever render path actually produced that surface -- the /// standard path's cached-flag approximation, or the authoritative @@ -303,6 +316,10 @@ class PageSurfaceCoordinator final : public QObject std::shared_ptr m_resourceBudget; QSet m_authoritativePages; + /// The proofing profile identity in force, empty when no soft proof is + /// requested. It joins every page's colour-managed output identity. + QString m_softProofProfile; + std::shared_ptr m_relay; quint64 m_generation = 1; diff --git a/LoopLibInteraction/sources/pagesurfacekey.cpp b/LoopLibInteraction/sources/pagesurfacekey.cpp index 64c6e923c..d809afebb 100644 --- a/LoopLibInteraction/sources/pagesurfacekey.cpp +++ b/LoopLibInteraction/sources/pagesurfacekey.cpp @@ -49,6 +49,13 @@ constexpr double MinimumBucketedZoom = 1e-6; /// identity. const QString AuthoritativeOverprintMarker = QStringLiteral("|overprint-authoritative"); +/// Appended to a colorOutputIdentity by withSoftProofMarker, carrying the +/// proofing profile identifier after the separator. Like the overprint marker +/// it is only ever appended, and the profile identifier is a Core-issued +/// identifier with no '|' in it, so parsing back is exact rather than +/// heuristic. +const QString SoftProofMarkerPrefix = QStringLiteral("|soft-proof:"); + } // namespace int zoomBucketFor(qreal zoom) @@ -134,6 +141,30 @@ bool hasAuthoritativeOverprintMarker(const QString& colorOutputIdentity) return colorOutputIdentity.endsWith(AuthoritativeOverprintMarker); } +QString withSoftProofMarker(const QString& colorOutputIdentity, const QString& proofProfileId) +{ + if (proofProfileId.isEmpty()) + { + return colorOutputIdentity; + } + return colorOutputIdentity + SoftProofMarkerPrefix + proofProfileId; +} + +bool hasSoftProofMarker(const QString& colorOutputIdentity) +{ + return colorOutputIdentity.contains(SoftProofMarkerPrefix); +} + +QString softProofProfileId(const QString& colorOutputIdentity) +{ + const qsizetype marker = colorOutputIdentity.indexOf(SoftProofMarkerPrefix); + if (marker < 0) + { + return QString(); + } + return colorOutputIdentity.mid(marker + SoftProofMarkerPrefix.size()); +} + const char* getSurfaceTerminalStateName(SurfaceTerminalState state) { switch (state) diff --git a/LoopLibInteraction/sources/pagesurfacekey.h b/LoopLibInteraction/sources/pagesurfacekey.h index ea517f31b..fa4e05cae 100644 --- a/LoopLibInteraction/sources/pagesurfacekey.h +++ b/LoopLibInteraction/sources/pagesurfacekey.h @@ -144,6 +144,25 @@ QString withAuthoritativeOverprintMarker(const QString& colorOutputIdentity); /// True if \p colorOutputIdentity was produced by withAuthoritativeOverprintMarker. bool hasAuthoritativeOverprintMarker(const QString& colorOutputIdentity); +/// Joins the soft-proof marker onto a colorOutputIdentity (issue #237). A soft +/// proof simulates the output intent through Core's colour management, so its +/// pixels are a distinct identity with their own cache slot and their own +/// admission -- exactly as the authoritative overprint marker is -- and the +/// marker carries the proofing profile's identifier, so the renderer proofs +/// through the profile the caller resolved rather than through a default. +/// +/// A proofed surface can therefore never be served from, or stand in for, the +/// fast or authoritative render of the same page, and the simulation's identity +/// travels with the pixels instead of being remembered beside them. +QString withSoftProofMarker(const QString& colorOutputIdentity, const QString& proofProfileId); + +/// True if \p colorOutputIdentity was produced by withSoftProofMarker. +bool hasSoftProofMarker(const QString& colorOutputIdentity); + +/// The proofing profile identifier a soft-proof identity carries, or an empty +/// string when the identity is not a soft proof. +QString softProofProfileId(const QString& colorOutputIdentity); + /// The zoom quantizer used by makePageSurfaceKey, exposed so a test can pin it /// rather than re-derive it. int zoomBucketFor(qreal zoom); diff --git a/LoopLibInteraction/sources/pagesurfacerenderer.cpp b/LoopLibInteraction/sources/pagesurfacerenderer.cpp index f85cbec4d..e1301fdc4 100644 --- a/LoopLibInteraction/sources/pagesurfacerenderer.cpp +++ b/LoopLibInteraction/sources/pagesurfacerenderer.cpp @@ -25,6 +25,7 @@ #include "pdfdocument.h" #include "pdfdocumentsession.h" #include "pdfexception.h" +#include "pdfcms.h" #include "pdfpagecachebudget.h" #include "pdfpainter.h" #include "pdfprocessingbudget.h" @@ -172,6 +173,125 @@ PageSurfaceResult renderAuthoritativeOverprint(const PageSurfaceRequest& request return result; } +/// Renders one page as a soft proof of the output intent (issue #237): the page +/// goes through PDFTransparencyRenderer with the separation-accurate policy, and +/// every colour it converts passes through a proofing CMS built for the profile +/// the caller's marker named. Core's own PDFLittleCMS implements the proof +/// (cmsCreateProofingTransform with the proofing profile and intent), so the +/// simulation is Core's colour management rather than a second approximation +/// invented here. +/// +/// The CMS is built per render from a local PDFCMSManager, the construction +/// PdfTool's ink tools already use, and never replaces the document session's +/// own CMS: proofing is scoped to the surfaces that asked for it, so the +/// authoritative render of the same page and every production consumer keep +/// rendering unproofed. +/// +/// A missing or unusable profile is a typed terminal state, never a silent +/// fallback to sRGB: the render fails and the caller reports the reason. +PageSurfaceResult renderSoftProof(const PageSurfaceRequest& request, + const pdf::PDFPage* page, + const pdf::PDFDocument* document, + pdf::PDFDocumentSession* session, + pdf::PDFJobContext& jobContext, + QSize pixelSize, + pdf::PDFRenderer::Features features) +{ + if (jobContext.isCancellationRequested()) + { + return makeTerminal(request, SurfaceTerminalState::Cancelled, QStringLiteral("page-surface/cancelled")); + } + + const QString proofProfileId = softProofProfileId(request.key.colorOutputIdentity); + if (proofProfileId.isEmpty()) + { + return makeTerminal(request, SurfaceTerminalState::Failed, QStringLiteral("page-surface/soft-proof-profile-missing")); + } + + pdf::PDFCMSManager proofCms(nullptr); + proofCms.setDocument(document, session->getProcessingBudget()); + + pdf::PDFCMSSettings settings = proofCms.getDefaultSettings(); + settings.system = pdf::PDFCMSSettings::System::LittleCMS2; + settings.isConsiderOutputIntent = true; + settings.isSoftProofing = true; + settings.softProofingProfile = proofProfileId; + proofCms.setSettings(settings); + + const pdf::PDFCMSPointer proofingCms = proofCms.getCurrentCMS(); + if (proofingCms.isNull()) + { + return makeTerminal(request, SurfaceTerminalState::Failed, QStringLiteral("page-surface/soft-proof-profile-unavailable")); + } + + pdf::PDFInkMapper inkMapper(&proofCms, document); + inkMapper.createSpotColors(true); + + pdf::PDFTransparencyRendererSettings rendererSettings; + rendererSettings.flags.setFlag(pdf::PDFTransparencyRendererSettings::SeparationSimulation, true); + rendererSettings.flags.setFlag(pdf::PDFTransparencyRendererSettings::SmoothImageTransformation, + features.testFlag(pdf::PDFRenderer::SmoothImages)); + rendererSettings.renderPolicy = pdf::PDFRenderPolicy::forSeparationPreview(); + + const QRectF deviceRect(QPointF(0.0, 0.0), QSizeF(pixelSize)); + const QTransform pagePointToDevice = pdf::PDFRenderer::createPagePointToDevicePointMatrix(page, deviceRect, request.key.rotation); + + pdf::PDFTransparencyRenderer renderer(page, + document, + session->getFontCache(), + proofingCms.data(), + session->getOptionalContentActivity(), + &inkMapper, + rendererSettings, + pagePointToDevice); + renderer.setOperationControl(jobContext.operationControl()); + + renderer.beginPaint(pixelSize); + renderer.processContents(); + renderer.endPaint(); + + if (jobContext.isCancellationRequested()) + { + return makeTerminal(request, SurfaceTerminalState::Cancelled, QStringLiteral("page-surface/cancelled")); + } + + const QColor paperColor = proofingCms->getPaperColor(); + QImage image = renderer.toImage(false, + true, + pdf::PDFRGB{ static_cast(paperColor.redF()), + static_cast(paperColor.greenF()), + static_cast(paperColor.blueF()) }); + if (image.isNull()) + { + return makeTerminal(request, SurfaceTerminalState::Failed, QStringLiteral("page-surface/render-failed")); + } + + image = applySurfaceFeatures(std::move(image), page, pagePointToDevice, features, proofingCms.data()); + image.setDevicePixelRatio(request.key.devicePixelRatio1000 / 1000.0); + + PageSurfaceResult result; + result.key = request.key; + result.token = request.token; + result.state = SurfaceTerminalState::Complete; + result.pixels = makeSurfaceBuffer(std::move(image)); + result.pixelSize = pixelSize; + result.byteSize = result.pixels ? result.pixels->byteSize : 0; + result.diagnostics = renderer.getRenderDiagnostics(); + + // A soft proof is a simulation of the output intent. Recording that here + // keeps it out of any exact-render claim: the surface describes what the + // proof did, and no consumer can read it as the authoritative render. + result.diagnostics.record(pdf::PDFRenderFidelity::SupportedWithFallback, + QStringLiteral("soft-proof/simulation")); + + if (!result.pixels) + { + return makeTerminal(request, SurfaceTerminalState::Failed, QStringLiteral("page-surface/empty-surface")); + } + + return result; +} + } // namespace PDFSessionPageSurfaceRenderer::PDFSessionPageSurfaceRenderer(pdf::PDFDocumentContext& context) : @@ -282,6 +402,14 @@ PageSurfaceResult PDFSessionPageSurfaceRenderer::render(const PageSurfaceRequest // exactly this work. jobContext.processingBudget().chargeRenderPixels(static_cast(pixelSize.width()) * static_cast(pixelSize.height()), QStringLiteral("page surface")); + if (hasSoftProofMarker(request.key.colorOutputIdentity)) + { + // The proofing simulation from issue #237: a separation-accurate + // render through Core's colour management under proofing settings, + // for the surfaces whose identity asked for it. + return renderSoftProof(request, page, document, session, jobContext, pixelSize, features); + } + if (hasAuthoritativeOverprintMarker(request.key.colorOutputIdentity)) { // The one-page authoritative escalation from issue #49: a full diff --git a/LoopLibQuick/CMakeLists.txt b/LoopLibQuick/CMakeLists.txt index 14e1cfc03..dc3bfd94a 100644 --- a/LoopLibQuick/CMakeLists.txt +++ b/LoopLibQuick/CMakeLists.txt @@ -44,6 +44,8 @@ qt_policy(SET QTP0001 NEW) qt_add_library(LoopLibQuick SHARED sources/loopquickglobal.h + sources/canvasdisplaycolor.cpp + sources/canvasdisplaycolor.h sources/canvaspalette.cpp sources/canvaspalette.h sources/canvasnodebuilder.cpp diff --git a/LoopLibQuick/sources/canvasdisplaycolor.cpp b/LoopLibQuick/sources/canvasdisplaycolor.cpp new file mode 100644 index 000000000..d05d1ce18 --- /dev/null +++ b/LoopLibQuick/sources/canvasdisplaycolor.cpp @@ -0,0 +1,143 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + + +#include "canvasdisplaycolor.h" + +#include "pdfcolorconvertor.h" + +namespace pdfquick +{ + +namespace +{ + +pdf::PDFColorConvertor::Mode convertorMode(CanvasDisplayColorMode mode) +{ + switch (mode) + { + case CanvasDisplayColorMode::Invert: + return pdf::PDFColorConvertor::Mode::InvertedColors; + case CanvasDisplayColorMode::Grayscale: + return pdf::PDFColorConvertor::Mode::Grayscale; + case CanvasDisplayColorMode::HighContrast: + return pdf::PDFColorConvertor::Mode::HighContrast; + case CanvasDisplayColorMode::Bitonal: + return pdf::PDFColorConvertor::Mode::Bitonal; + case CanvasDisplayColorMode::Custom: + return pdf::PDFColorConvertor::Mode::CustomColors; + case CanvasDisplayColorMode::Normal: + break; + } + return pdf::PDFColorConvertor::Mode::Normal; +} + +} // namespace + +QString getCanvasDisplayColorModeName(CanvasDisplayColorMode mode) +{ + switch (mode) + { + case CanvasDisplayColorMode::Normal: + return QStringLiteral("normal"); + case CanvasDisplayColorMode::Invert: + return QStringLiteral("invert"); + case CanvasDisplayColorMode::Grayscale: + return QStringLiteral("grayscale"); + case CanvasDisplayColorMode::HighContrast: + return QStringLiteral("high-contrast"); + case CanvasDisplayColorMode::Bitonal: + return QStringLiteral("bitonal"); + case CanvasDisplayColorMode::Custom: + return QStringLiteral("custom"); + } + return QStringLiteral("normal"); +} + +std::optional canvasDisplayColorModeFromName(const QString& name) +{ + const QString normalized = name.trimmed().toLower(); + + if (normalized == QLatin1String("normal")) + { + return CanvasDisplayColorMode::Normal; + } + if (normalized == QLatin1String("invert")) + { + return CanvasDisplayColorMode::Invert; + } + if (normalized == QLatin1String("grayscale")) + { + return CanvasDisplayColorMode::Grayscale; + } + if (normalized == QLatin1String("high-contrast")) + { + return CanvasDisplayColorMode::HighContrast; + } + if (normalized == QLatin1String("bitonal")) + { + return CanvasDisplayColorMode::Bitonal; + } + if (normalized == QLatin1String("custom")) + { + return CanvasDisplayColorMode::Custom; + } + return std::nullopt; +} + +QString getCanvasDisplayColorModeActionId(CanvasDisplayColorMode mode) +{ + switch (mode) + { + case CanvasDisplayColorMode::Normal: + return QString(); + case CanvasDisplayColorMode::Invert: + return QStringLiteral("actionColorInvert"); + case CanvasDisplayColorMode::Grayscale: + return QStringLiteral("actionColorGrayscale"); + case CanvasDisplayColorMode::HighContrast: + return QStringLiteral("actionColorHighContrast"); + case CanvasDisplayColorMode::Bitonal: + return QStringLiteral("actionColorBitonal"); + case CanvasDisplayColorMode::Custom: + return QStringLiteral("actionColorCustom"); + } + return QString(); +} + +QImage applyCanvasDisplayColorMode(const QImage& image, const CanvasDisplayColorSettings& settings) +{ + if (!settings.isActive() || image.isNull()) + { + return image; + } + + pdf::PDFColorConvertor convertor; + convertor.setMode(convertorMode(settings.mode)); + convertor.setBitonalThreshold(settings.bitonalThreshold); + convertor.setHighContrastBrightnessFactor(settings.highContrastSlopeFactor); + convertor.setForegroundColor(settings.customForeground); + convertor.setBackgroundColor(settings.customBackground); + return convertor.convert(image); +} + +} // namespace pdfquick diff --git a/LoopLibQuick/sources/canvasdisplaycolor.h b/LoopLibQuick/sources/canvasdisplaycolor.h new file mode 100644 index 000000000..b5156ca35 --- /dev/null +++ b/LoopLibQuick/sources/canvasdisplaycolor.h @@ -0,0 +1,93 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + + +#ifndef CANVASDISPLAYCOLOR_H +#define CANVASDISPLAYCOLOR_H + +#include "loopquickglobal.h" + +#include +#include +#include + +#include + +namespace pdfquick +{ + +/// The five display colour modes of issue #235, plus the identity. +/// +/// A display colour mode is a presentation filter over pixels the render path +/// already produced. It is deliberately not a render request: the mode never +/// reaches a PageSurfaceKey, the page cache, `PDFRenderer::Features` or +/// `PDFRenderPolicy`, so no surface can be mistaken for the pixels a production +/// render would have produced, and switching modes re-renders nothing (issue +/// #235 display half; the production half -- bitonal document creation, bleed +/// fixup -- belongs to PR 3 and is not here). +/// +/// The transform itself is Core's own `pdf::PDFColorConvertor`, the same +/// converter `PDFRenderer::applyFeaturesToColorConvertor` configures for the +/// ColorAdjust_* render features, so "invert" means one thing in this repository +/// rather than two. +enum class CanvasDisplayColorMode +{ + Normal, + Invert, + Grayscale, + HighContrast, + Bitonal, + Custom +}; + +/// The vocabulary name of a mode, as `actionColor*` commands and QML use it. +LOOPLIBQUICK_EXPORT QString getCanvasDisplayColorModeName(CanvasDisplayColorMode mode); + +/// Parses a name once at a boundary. An unknown name returns nothing rather +/// than being coerced into a default. +LOOPLIBQUICK_EXPORT std::optional canvasDisplayColorModeFromName(const QString& name); + +/// The shell action id that selects a mode, for the handler the host binds. +LOOPLIBQUICK_EXPORT QString getCanvasDisplayColorModeActionId(CanvasDisplayColorMode mode); + +/// Everything the transform reads. The custom colours and the bitonal threshold +/// are settings, not modes, so they stay valid while the mode changes. +struct CanvasDisplayColorSettings +{ + CanvasDisplayColorMode mode = CanvasDisplayColorMode::Normal; + QColor customForeground = Qt::green; + QColor customBackground = Qt::black; + int bitonalThreshold = 128; + float highContrastSlopeFactor = 10.0F; + + bool isActive() const noexcept { return mode != CanvasDisplayColorMode::Normal; } + + bool operator==(const CanvasDisplayColorSettings&) const = default; +}; + +/// The display transform. Pure: the input image is not modified and a Normal +/// setting returns it unchanged. +LOOPLIBQUICK_EXPORT QImage applyCanvasDisplayColorMode(const QImage& image, const CanvasDisplayColorSettings& settings); + +} // namespace pdfquick + +#endif // CANVASDISPLAYCOLOR_H diff --git a/LoopLibQuick/sources/canvasnodebuilder.cpp b/LoopLibQuick/sources/canvasnodebuilder.cpp index 1ef05b417..74189ddbe 100644 --- a/LoopLibQuick/sources/canvasnodebuilder.cpp +++ b/LoopLibQuick/sources/canvasnodebuilder.cpp @@ -23,6 +23,8 @@ #include "canvasnodebuilder.h" +#include "canvasdisplaycolor.h" + #include #include #include @@ -244,6 +246,24 @@ void CanvasNodeBuilder::setPalette(const CanvasPalette& palette) } } +void CanvasNodeBuilder::setDisplayColorSettings(const CanvasDisplayColorSettings& settings) +{ + if (m_displayColor == settings) + { + return; + } + + m_displayColor = settings; + + // Every retained copy described the previous setting. Dropping the source + // identity (and not the node) makes the next sync re-convert and re-upload + // each tile, which is the whole cost of a mode change. + for (auto& tile : m_tiles) + { + tile.second.displaySource = nullptr; + } +} + void CanvasNodeBuilder::forget() { m_tiles.clear(); @@ -296,19 +316,38 @@ void CanvasNodeBuilder::syncTiles(QSGNode* parent, const CanvasSnapshot& snapsho entry.node->setFiltering(QSGTexture::Linear); } + // The display colour filter is applied here, on the render thread, over + // pixels that were already admitted. The copy is retained per tile so a + // mode that did not change costs no conversion and no upload. + pdfinteraction::SurfaceBufferPointer drawn = tile.pixels; + if (m_displayColor.isActive()) + { + if (entry.displaySource != tile.pixels.get() || entry.displaySettings != m_displayColor) + { + entry.displayPixels = pdfinteraction::makeSurfaceBuffer( + applyCanvasDisplayColorMode(tile.pixels->image, m_displayColor)); + entry.displaySource = tile.pixels.get(); + entry.displaySettings = m_displayColor; + } + if (entry.displayPixels) + { + drawn = entry.displayPixels; + } + } + // Pointer identity is the whole test. SurfaceBuffer is immutable and // shared, so the same pointer is necessarily the same pixels and the // upload is skipped -- which is what keeps a pan from re-uploading every // visible page. - if (entry.pixels != tile.pixels) + if (entry.pixels != drawn) { entry.resourceReservation.reset(); - const QQuickWindow::CreateTextureOptions options = tile.pixels->image.hasAlphaChannel() + const QQuickWindow::CreateTextureOptions options = drawn->image.hasAlphaChannel() ? QQuickWindow::TextureHasAlphaChannel : QQuickWindow::TextureIsOpaque; if (m_resourceBudget) { - const qsizetype bytes = tile.pixels->image.sizeInBytes(); + const qsizetype bytes = drawn->image.sizeInBytes(); if (bytes <= 0 || !m_resourceBudget->tryReserve(pdf::PDFResourcePool::GpuTextureCache, bytes, pdf::PDFResourcePriority::Visible, @@ -323,15 +362,15 @@ void CanvasNodeBuilder::syncTiles(QSGNode* parent, const CanvasSnapshot& snapsho continue; } entry.resourceReservation = std::make_shared(m_resourceBudget, - pdf::PDFResourcePool::GpuTextureCache, - bytes); + pdf::PDFResourcePool::GpuTextureCache, + bytes); } - entry.node->setTexture(m_window->createTextureFromImage(tile.pixels->image, options)); - entry.pixels = tile.pixels; + entry.node->setTexture(m_window->createTextureFromImage(drawn->image, options)); + entry.pixels = drawn; } else if (m_resourceBudget && !entry.resourceReservation) { - const qsizetype bytes = tile.pixels->image.sizeInBytes(); + const qsizetype bytes = drawn->image.sizeInBytes(); if (bytes <= 0 || !m_resourceBudget->tryReserve(pdf::PDFResourcePool::GpuTextureCache, bytes, pdf::PDFResourcePriority::Visible, @@ -346,8 +385,8 @@ void CanvasNodeBuilder::syncTiles(QSGNode* parent, const CanvasSnapshot& snapsho continue; } entry.resourceReservation = std::make_shared(m_resourceBudget, - pdf::PDFResourcePool::GpuTextureCache, - bytes); + pdf::PDFResourcePool::GpuTextureCache, + bytes); } entry.placedRect = tile.placedRect; @@ -368,7 +407,7 @@ void CanvasNodeBuilder::syncTiles(QSGNode* parent, const CanvasSnapshot& snapsho parent->appendChildNode(entry.node); retained.insert({ tile.key, entry }); - retainedBytes += qint64(tile.pixels->image.sizeInBytes()); + retainedBytes += qint64(drawn->image.sizeInBytes()); ++m_tileCount; if (!tile.exact) diff --git a/LoopLibQuick/sources/canvasnodebuilder.h b/LoopLibQuick/sources/canvasnodebuilder.h index ec8a220f4..ecb7a20f2 100644 --- a/LoopLibQuick/sources/canvasnodebuilder.h +++ b/LoopLibQuick/sources/canvasnodebuilder.h @@ -24,6 +24,7 @@ #ifndef CANVASNODEBUILDER_H #define CANVASNODEBUILDER_H +#include "canvasdisplaycolor.h" #include "canvaspalette.h" #include "loopquickglobal.h" @@ -92,6 +93,15 @@ class LOOPLIBQUICK_EXPORT CanvasNodeBuilder void setPalette(const CanvasPalette& palette); const CanvasPalette& palette() const noexcept { return m_palette; } + /// The display colour filter applied to every tile this builder presents + /// (issue #235 display half). It is presentation state, not render state: + /// the filter runs here, over pixels that were already admitted, and never + /// reaches a surface request or a PageSurfaceKey. Changing it re-converts + /// the retained tiles against the new setting; it asks nothing of a + /// coordinator. + void setDisplayColorSettings(const CanvasDisplayColorSettings& settings); + const CanvasDisplayColorSettings& displayColorSettings() const noexcept { return m_displayColor; } + /// Page pixels. `parent` is emptied of tiles that the snapshot no longer /// contains, and every surviving tile keeps its texture. /// @@ -160,6 +170,15 @@ class LOOPLIBQUICK_EXPORT CanvasNodeBuilder pdfinteraction::SurfaceBufferPointer pixels; std::shared_ptr resourceReservation; + /// The display-filtered copy of the tile for the settings it was built + /// with, when a display colour mode is active. `displaySource` is a key, + /// not an owner: `pixels` above keeps the source buffer alive for as + /// long as this entry exists, and the two are compared to decide whether + /// the copy still describes the frame. + pdfinteraction::SurfaceBufferPointer displayPixels; + const pdfinteraction::SurfaceBuffer* displaySource = nullptr; + CanvasDisplayColorSettings displaySettings; + QRect placedRect; bool exact = true; }; @@ -186,6 +205,7 @@ class LOOPLIBQUICK_EXPORT CanvasNodeBuilder QQuickWindow* m_window = nullptr; std::shared_ptr m_resourceBudget; CanvasPalette m_palette = CanvasPalette::standard(); + CanvasDisplayColorSettings m_displayColor; std::map m_tiles; QHash m_overlays; diff --git a/LoopLibQuick/sources/loopcanvasitem.cpp b/LoopLibQuick/sources/loopcanvasitem.cpp index 90e0c54f8..70ebed261 100644 --- a/LoopLibQuick/sources/loopcanvasitem.cpp +++ b/LoopLibQuick/sources/loopcanvasitem.cpp @@ -23,6 +23,7 @@ #include "loopcanvasitem.h" +#include "canvasdisplaycolor.h" #include "loopcanvasaccessible.h" #include "interactioncontroller.h" #include "pagesurfacecoordinator.h" @@ -259,6 +260,66 @@ void LoopCanvasItem::setHighContrast(bool highContrast) requestFrame(); } +QString LoopCanvasItem::displayColorMode() const +{ + return getCanvasDisplayColorModeName(m_displayColor.mode); +} + +bool LoopCanvasItem::setDisplayColorMode(const QString& modeName) +{ + const std::optional mode = canvasDisplayColorModeFromName(modeName); + if (!mode || m_displayColor.mode == *mode) + { + return mode.has_value(); + } + + m_displayColor.mode = *mode; + m_displayColorDirty = true; + Q_EMIT displayColorChanged(); + requestFrame(); + return true; +} + +void LoopCanvasItem::setDisplayColorCustomForeground(const QColor& color) +{ + if (!color.isValid() || m_displayColor.customForeground == color) + { + return; + } + + m_displayColor.customForeground = color; + m_displayColorDirty = true; + Q_EMIT displayColorChanged(); + requestFrame(); +} + +void LoopCanvasItem::setDisplayColorCustomBackground(const QColor& color) +{ + if (!color.isValid() || m_displayColor.customBackground == color) + { + return; + } + + m_displayColor.customBackground = color; + m_displayColorDirty = true; + Q_EMIT displayColorChanged(); + requestFrame(); +} + +void LoopCanvasItem::setDisplayColorBitonalThreshold(int threshold) +{ + const int clamped = qBound(1, threshold, 254); + if (m_displayColor.bitonalThreshold == clamped) + { + return; + } + + m_displayColor.bitonalThreshold = clamped; + m_displayColorDirty = true; + Q_EMIT displayColorChanged(); + requestFrame(); +} + QString LoopCanvasItem::accessibleDocumentSummary() const { return m_accessibleDocumentSummary; @@ -290,6 +351,17 @@ void LoopCanvasItem::refreshPalette() m_overlaysDirty = true; } +void LoopCanvasItem::refreshDisplayColor() +{ + m_builder.setDisplayColorSettings(m_displayColor); + m_displayColorDirty = false; + + // The builder dropped the retained copies; the tiles must be synced again + // for the new mode to reach a texture. No surface is asked for: the filter + // reads pixels that are already admitted. + m_tilesDirty = true; +} + CanvasFrameStats LoopCanvasItem::frameStats() const { CanvasFrameStats stats; diff --git a/LoopLibQuick/sources/loopcanvasitem.h b/LoopLibQuick/sources/loopcanvasitem.h index 4eac2a96b..d27398d58 100644 --- a/LoopLibQuick/sources/loopcanvasitem.h +++ b/LoopLibQuick/sources/loopcanvasitem.h @@ -100,6 +100,13 @@ class LOOPLIBQUICK_EXPORT LoopCanvasItem : public QQuickItem Q_PROPERTY(QString activeTool READ activeTool NOTIFY activeToolChanged) Q_PROPERTY(bool traceOverlayVisible READ isTraceOverlayVisible WRITE setTraceOverlayVisible NOTIFY traceOverlayVisibleChanged) Q_PROPERTY(bool highContrast READ isHighContrast WRITE setHighContrast NOTIFY highContrastChanged) + /// Display colour modes (issue #235 display half). They filter the pixels + /// this item already holds; they are not render requests, so setting one + /// re-renders nothing and no surface identity depends on them. + Q_PROPERTY(QString displayColorMode READ displayColorMode WRITE setDisplayColorMode NOTIFY displayColorChanged) + Q_PROPERTY(QColor displayColorCustomForeground READ displayColorCustomForeground WRITE setDisplayColorCustomForeground NOTIFY displayColorChanged) + Q_PROPERTY(QColor displayColorCustomBackground READ displayColorCustomBackground WRITE setDisplayColorCustomBackground NOTIFY displayColorChanged) + Q_PROPERTY(int displayColorBitonalThreshold READ displayColorBitonalThreshold WRITE setDisplayColorBitonalThreshold NOTIFY displayColorChanged) Q_PROPERTY(QString accessibleDocumentSummary READ accessibleDocumentSummary WRITE setAccessibleDocumentSummary NOTIFY accessibleDocumentSummaryChanged) public: @@ -149,6 +156,18 @@ class LOOPLIBQUICK_EXPORT LoopCanvasItem : public QQuickItem bool isHighContrast() const noexcept { return m_highContrast; } void setHighContrast(bool highContrast); + QString displayColorMode() const; + /// Parses a display colour mode name once at this boundary and returns false + /// for a name outside the vocabulary, leaving the mode in place. + bool setDisplayColorMode(const QString& modeName); + bool isDisplayColorModeActive() const noexcept { return m_displayColor.isActive(); } + QColor displayColorCustomForeground() const noexcept { return m_displayColor.customForeground; } + void setDisplayColorCustomForeground(const QColor& color); + QColor displayColorCustomBackground() const noexcept { return m_displayColor.customBackground; } + void setDisplayColorCustomBackground(const QColor& color); + int displayColorBitonalThreshold() const noexcept { return m_displayColor.bitonalThreshold; } + void setDisplayColorBitonalThreshold(int threshold); + QString accessibleDocumentSummary() const; void setAccessibleDocumentSummary(const QString& summary); void notifyAccessibilityUpdate(); @@ -164,6 +183,7 @@ class LOOPLIBQUICK_EXPORT LoopCanvasItem : public QQuickItem void activeToolChanged(); void traceOverlayVisibleChanged(); void highContrastChanged(); + void displayColorChanged(); void accessibleDocumentSummaryChanged(); protected: @@ -211,6 +231,10 @@ class LOOPLIBQUICK_EXPORT LoopCanvasItem : public QQuickItem void publishViewportGeometry(); void refreshPalette(); + /// Pushes the display colour settings to the node builder and marks the + /// tiles for a re-sync. Called from prepareSceneGraph, on the render thread, + /// where the builder may be touched at all. + void refreshDisplayColor(); /// Reconnects the window-lifetime signals. Called from itemChange for every /// scene change, including the change to nullptr when the item is removed @@ -254,6 +278,7 @@ class LOOPLIBQUICK_EXPORT LoopCanvasItem : public QQuickItem bool m_traceOverlayVisible = false; bool m_highContrast = false; + CanvasDisplayColorSettings m_displayColor; QString m_accessibleDocumentSummary; /// Set when the snapshot or the frame changed and the scene graph has not @@ -281,6 +306,9 @@ class LOOPLIBQUICK_EXPORT LoopCanvasItem : public QQuickItem bool m_tilesDirty = true; bool m_overlaysDirty = true; bool m_paletteDirty = true; + /// Set on the GUI thread, acted on in prepareSceneGraph: the builder is + /// render-thread state, so a mode change only raises this flag there. + bool m_displayColorDirty = true; /// Written in updatePaintNode on the render thread, read on the GUI thread /// by frameStats(). Plain ints: the GUI thread is blocked while diff --git a/LoopLibQuick/sources/loopcanvasitemscene.cpp b/LoopLibQuick/sources/loopcanvasitemscene.cpp index a69aca768..abbfe3964 100644 --- a/LoopLibQuick/sources/loopcanvasitemscene.cpp +++ b/LoopLibQuick/sources/loopcanvasitemscene.cpp @@ -35,6 +35,11 @@ QSGNode* LoopCanvasItem::prepareSceneGraph(QSGNode* oldNode, QQuickWindow* hostW refreshPalette(); } + if (m_displayColorDirty) + { + refreshDisplayColor(); + } + if (m_builderResetPending.exchange(false)) { m_builder.forget(); @@ -139,8 +144,8 @@ int LoopCanvasItem::syncOverlays(QSGNode* root, qreal pixelScale) } void LoopCanvasItem::syncTraceHud(QSGNode* root, - QQuickWindow* hostWindow, - qreal devicePixelRatio) + QQuickWindow* hostWindow, + qreal devicePixelRatio) { const bool wantHud = m_traceOverlayVisible && m_recorder; QSGImageNode* hud = root->childCount() > HudChild diff --git a/LoopLibQuick/sources/loopstatevisual.cpp b/LoopLibQuick/sources/loopstatevisual.cpp index 7ca2a1955..b6bf5551c 100644 --- a/LoopLibQuick/sources/loopstatevisual.cpp +++ b/LoopLibQuick/sources/loopstatevisual.cpp @@ -267,12 +267,14 @@ LoopStateVisual resolveFixLifecycleStateVisual(const QString& stateName) QStringLiteral("No correction planned") }; } -QString classifyPreviewFidelityState(bool hasDocument, bool stale, bool authoritative, bool exact) +QString classifyPreviewFidelityState(bool hasDocument, bool stale, bool authoritative, bool exact, const QString& simulation) { - // The host owns these four facts; this orders them and names the result. An open + // The host owns these facts; this orders them and names the result. An open // document whose preview belongs to an earlier revision is stale before it is - // anything else, and an authoritative render is authoritative before it is judged - // on its (ascertained later) diagnostics. + // anything else; a simulation is a simulation before the render underneath it is + // judged, because the display transform is what the operator is looking at; and an + // authoritative render is authoritative before it is judged on its (ascertained + // later) diagnostics. if (!hasDocument) { return QStringLiteral("unavailable"); @@ -281,6 +283,10 @@ QString classifyPreviewFidelityState(bool hasDocument, bool stale, bool authorit { return QStringLiteral("stale"); } + if (!simulation.trimmed().isEmpty()) + { + return simulation.trimmed().toLower(); + } if (authoritative) { return QStringLiteral("authoritative"); @@ -305,6 +311,12 @@ QString previewFidelityOriginName(const QString& stateName) { return QStringLiteral("fast-canvas"); } + if (normalized == QLatin1String("display-filter") || normalized == QLatin1String("soft-proof")) + { + // The simulation names its own path: neither the fast canvas nor the + // output-preview render produced what is on screen. + return normalized; + } return QStringLiteral("none"); } @@ -333,6 +345,20 @@ LoopStateVisual resolvePreviewFidelityStateVisual(const QString& stateName) return { StateKind::Incomplete, ColorRole::StateIncomplete, StateIcon::Hatched, QStringLiteral("Preview is stale for the current revision") }; } + if (normalized == QLatin1String("display-filter")) + { + // A display colour mode is a presentation filter: the pixels are a simulation of + // the page, never a production render and never approval evidence. + return { StateKind::Warning, ColorRole::SeverityWarning, StateIcon::Slash, + QStringLiteral("Display colour simulation, not a production render") }; + } + if (normalized == QLatin1String("soft-proof")) + { + // Soft proof simulates the output intent through colour management. It is a + // simulation of print, not the print's evidence. + return { StateKind::Warning, ColorRole::SeverityWarning, StateIcon::HalfFilled, + QStringLiteral("Soft-proof simulation of the output intent") }; + } // "unavailable", an empty string and anything unrecognised: no preview to trust. return { StateKind::NotChecked, ColorRole::StateNotChecked, StateIcon::Outline, diff --git a/LoopLibQuick/sources/loopstatevisual.h b/LoopLibQuick/sources/loopstatevisual.h index 1a6b1972c..003538999 100644 --- a/LoopLibQuick/sources/loopstatevisual.h +++ b/LoopLibQuick/sources/loopstatevisual.h @@ -121,18 +121,26 @@ LOOPLIBQUICK_EXPORT LoopStateVisual resolvePreflightStateVisual(const QString& s LOOPLIBQUICK_EXPORT LoopStateVisual resolveFixLifecycleStateVisual(const QString& stateName); /// Render fidelity and origin of the interactive preview (#28). The state name is derived -/// from four facts the host already holds: whether a document is open, whether the preview -/// is stale for the open revision, whether the current page is showing the authoritative -/// output-preview render, and whether that page's render is diagnostic-exact. +/// from facts the host already holds: whether a document is open, whether the preview is +/// stale for the open revision, whether a display simulation is in force, whether the +/// current page is showing the authoritative output-preview render, and whether that page's +/// render is diagnostic-exact. /// /// `approximate` is the overprint-sensitive page on the fast canvas path: overprint is not /// simulated there, so those pixels are never proof of print-safe output. `authoritative` /// is the only state that names the output-preview origin. No preview state reaches a pass /// treatment: the interactive preview proves a page's render path, never publication safety. -LOOPLIBQUICK_EXPORT QString classifyPreviewFidelityState(bool hasDocument, bool stale, bool authoritative, bool exact); +/// +/// `simulation` is the presentation filter or the proofing simulation in force -- issue +/// #235's display colour modes (`display-filter`) or issue #237's soft proof +/// (`soft-proof`). It outranks `authoritative`: the pixels on screen have been transformed +/// for the display, so they are a simulation of the page whatever render produced them, and +/// no simulation may be read as the authoritative render or as approval evidence. +LOOPLIBQUICK_EXPORT QString classifyPreviewFidelityState(bool hasDocument, bool stale, bool authoritative, bool exact, const QString& simulation = {}); /// Which render path produced the preview's pixels, for a state name above: `none`, -/// `fast-canvas` or `output-preview`. Rendering only, no analysis. +/// `fast-canvas`, `output-preview`, `display-filter` or `soft-proof`. Rendering only, no +/// analysis. LOOPLIBQUICK_EXPORT QString previewFidelityOriginName(const QString& stateName); /// Canonical treatment for a preview fidelity state name. Each state takes a shape no other diff --git a/UnitTests/tst_documentfacadetest.cpp b/UnitTests/tst_documentfacadetest.cpp index ed124cc98..5526f0134 100644 --- a/UnitTests/tst_documentfacadetest.cpp +++ b/UnitTests/tst_documentfacadetest.cpp @@ -436,7 +436,9 @@ void DocumentFacadeTest::catalogLoadsTheWholeEditorActionSet() QVERIFY(descriptor.capability != pdfinteraction::CommandCapability::Unclassified); } } - QCOMPARE(implemented, 26); + // The five display colour modes joined the implemented set in epic #231 PR1 + // (issue #235 display half), each with the `none` capability. + QCOMPARE(implemented, 31); } void DocumentFacadeTest::catalogPublishesAvailabilityAtomically() diff --git a/UnitTests/tst_editorhosttest.cpp b/UnitTests/tst_editorhosttest.cpp index 3bd15b0ea..fe1f2dafd 100644 --- a/UnitTests/tst_editorhosttest.cpp +++ b/UnitTests/tst_editorhosttest.cpp @@ -307,6 +307,12 @@ private slots: void fixRollbackReturnsToARecordedRevision(); void previewFidelityNamesTheOriginAndSwitchesExplicitly(); void refusedRenderCannotBecomeAuthoritativeEvidence(); + void displayColorModesChangePresentationAndAreNeverProductionEvidence(); + void displayColorModeNamesAreParsedOnceAtTheBoundary(); + void softProofSimulatesThroughCoreColourManagementAndNeverBecomesEvidence(); + void softProofWithoutAUsableOutputIntentIsAVisibleState(); + void platesListSeparationsAndCarryVisibilityWithoutTouchingTheDocument(); + void inkCoverageReadoutAtAPointIsMeasuredOrExplicitlyUnavailable(); }; void EditorHostTest::encryptedOpen_data() @@ -2392,6 +2398,374 @@ void EditorHostTest::executeApprovedActionListPlanRefusesAnUnreviewedPlan() QVERIFY(host.executeApprovedActionListPlan()); } -QTEST_GUILESS_MAIN(EditorHostTest) +/// Issue #235, display half. A display colour mode changes what the canvas shows and +/// nothing else: it is marked a simulation by the fidelity indicator, and neither the +/// Compare workspace nor the production identity can see it. The failure case is a +/// display mode mistaken for a production render. +void EditorHostTest::displayColorModesChangePresentationAndAreNeverProductionEvidence() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + + pdf::PDFDocumentBuilder builder; + builder.appendPage(QRectF(0, 0, 612, 792)); + const QString path = directory.filePath(QStringLiteral("display-modes.pdf")); + { + const pdf::PDFDocument document = builder.build(); + pdf::PDFDocumentWriter writer(nullptr); + QVERIFY(writer.write(path, &document, true)); + } + + EditorHost host; + host.openFileUrl(QUrl::fromLocalFile(path)); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 15000); + host.setViewportGeometry(96.0 / 25.4, 1.0, 1024, 768); + QTRY_COMPARE_WITH_TIMEOUT(host.previewFidelityStateName(), QStringLiteral("exact"), 30000); + + // Every display mode is a bound command with a document open. + const QStringList actions = { QStringLiteral("actionColorInvert"), QStringLiteral("actionColorGrayscale"), + QStringLiteral("actionColorHighContrast"), QStringLiteral("actionColorBitonal"), + QStringLiteral("actionColorCustom") }; + for (const QString& action : actions) + { + QVERIFY2(host.isCommandEnabled(action), qPrintable(action)); + } + + const QVariantMap compareBefore = host.compareReview(); + const QVariantMap identityBefore = host.previewIdentity(); + const QVariantMap signOffBefore = host.fixSignOff(); + const QString revisionBefore = host.sessionForTest()->facade().currentRevision().toString(); + + // The menu entry works, and what it produces is a simulation -- not the render + // path's fidelity, and never a pass. + QVERIFY(host.invokeCommand(QStringLiteral("actionColorInvert")) != 0); + QCOMPARE(host.displayColorModeName(), QStringLiteral("invert")); + QVERIFY(host.displayColorModeActive()); + QCOMPARE(host.previewFidelityStateName(), QStringLiteral("display-filter")); + QCOMPARE(host.previewFidelityOriginName(), QStringLiteral("display-filter")); + QVERIFY(host.previewFidelitySummary().contains(QStringLiteral("never recorded as approval evidence"))); + + const QVariantMap visual = host.previewFidelityVisual(); + QCOMPARE(visual.value(QStringLiteral("kind")).toString(), QStringLiteral("Warning")); + QVERIFY(visual.value(QStringLiteral("kind")).toString() != QStringLiteral("Passed")); + QVERIFY(visual.value(QStringLiteral("colorRole")).toString() != QStringLiteral("Success")); + QVERIFY(!visual.value(QStringLiteral("accessibleName")).toString().trimmed().isEmpty()); + + // The failure case: nothing in Compare or sign-off may mistake it for a production + // render. The comparison, the preview identity and the sign-off are the same facts + // they were before the mode existed. + QCOMPARE(host.compareReview(), compareBefore); + QCOMPARE(host.previewIdentity(), identityBefore); + QCOMPARE(host.fixSignOff(), signOffBefore); + QVERIFY(!host.fixPreview().value(QStringLiteral("available")).toBool()); + QCOMPARE(host.sessionForTest()->facade().currentRevision().toString(), revisionBefore); + + // Invoking the mode already in force returns to normal: there is no sixth action, + // and a display mode with no way back is a trap. + QVERIFY(host.invokeCommand(QStringLiteral("actionColorInvert")) != 0); + QCOMPARE(host.displayColorModeName(), QStringLiteral("normal")); + QVERIFY(!host.displayColorModeActive()); + QTRY_COMPARE_WITH_TIMEOUT(host.previewFidelityStateName(), QStringLiteral("exact"), 30000); + + // Each of the remaining modes is reachable and distinct. + for (const QString& action : { QStringLiteral("actionColorGrayscale"), QStringLiteral("actionColorHighContrast"), + QStringLiteral("actionColorBitonal"), QStringLiteral("actionColorCustom") }) + { + QVERIFY(host.invokeCommand(action) != 0); + QVERIFY(host.displayColorModeActive()); + QCOMPARE(host.previewFidelityStateName(), QStringLiteral("display-filter")); + } + QCOMPARE(host.displayColorModeName(), QStringLiteral("custom")); + QVERIFY(host.invokeCommand(QStringLiteral("actionColorCustom")) != 0); + QCOMPARE(host.displayColorModeName(), QStringLiteral("normal")); +} + +void EditorHostTest::displayColorModeNamesAreParsedOnceAtTheBoundary() +{ + EditorHost host; + + QVERIFY(!host.setDisplayColorMode(QStringLiteral("sepia"))); + QCOMPARE(host.displayColorModeName(), QStringLiteral("normal")); + QVERIFY(!host.setDisplayColorMode(QString())); + QCOMPARE(host.displayColorModeName(), QStringLiteral("normal")); + + QVERIFY(host.setDisplayColorMode(QStringLiteral("Grayscale"))); + QCOMPARE(host.displayColorModeName(), QStringLiteral("grayscale")); + QVERIFY(host.setDisplayColorMode(QStringLiteral("normal"))); + + // The custom mode's settings are validated at the boundary too: an invalid colour + // or an out-of-range threshold is refused rather than coerced. + QVERIFY(host.setDisplayColorCustomColors(QColor(10, 20, 30), QColor(200, 210, 220))); + QCOMPARE(host.displayColorCustomForeground(), QColor(10, 20, 30)); + QCOMPARE(host.displayColorCustomBackground(), QColor(200, 210, 220)); + QVERIFY(!host.setDisplayColorCustomColors(QColor(), QColor(200, 210, 220))); + QVERIFY(!host.setDisplayColorCustomColors(QColor(10, 20, 30), QColor())); + + QVERIFY(host.setDisplayColorBitonalThreshold(200)); + QCOMPARE(host.displayColorBitonalThreshold(), 200); + QVERIFY(!host.setDisplayColorBitonalThreshold(0)); + QVERIFY(!host.setDisplayColorBitonalThreshold(255)); + QCOMPARE(host.displayColorBitonalThreshold(), 200); +} + +/// Issue #237. The soft proof renders through Core's colour management under proofing +/// settings, shows the profile identity, and is marked a simulation. It is never +/// recorded as approval evidence, and it never replaces the document session's own CMS. +void EditorHostTest::softProofSimulatesThroughCoreColourManagementAndNeverBecomesEvidence() +{ + const QString path = preflightFixturesDir() + QStringLiteral("/output-intent-cmyk.pdf"); + QVERIFY2(QFileInfo::exists(path), qPrintable(path)); + + EditorHost host; + host.openFileUrl(QUrl::fromLocalFile(path)); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 30000); + // A page's fidelity is only reported once the shell has a layout and the + // page's surface has been requested. + host.setViewportGeometry(96.0 / 25.4, 1.0, 1024, 768); + QTRY_COMPARE_WITH_TIMEOUT(host.currentPage(), 0, 30000); + + QCOMPARE(host.softProofStateName(), QStringLiteral("off")); + QVERIFY(!host.softProofActive()); + QVERIFY(!host.softProofSummary().trimmed().isEmpty()); + + QVERIFY(host.setSoftProofEnabled(true)); + QCOMPARE(host.softProofStateName(), QStringLiteral("simulating")); + QVERIFY(host.softProofActive()); + QVERIFY(!host.softProofProfileId().isEmpty()); + QCOMPARE(host.softProofProfileTypeName(), QStringLiteral("output-intent")); + // The identity shown is the profile's: its Core-issued identifier always, and + // its name when the profile carries one. + QVERIFY(host.softProofSummary().contains(host.softProofProfileId())); + QVERIFY(host.softProofSummary().contains(host.softProofProfileName().isEmpty() + ? host.softProofProfileId() + : host.softProofProfileName())); + + // The proof is its own render identity: the surfaces re-render as proofs, through + // Core's colour management, and the diagnostics carry the simulation's reason. + // Until that render is admitted the indicator honestly says `unavailable`, so the + // simulation state is asserted once the proof is on screen. + auto* surfaces = host.sessionForTest()->surfaces(); + QVERIFY(surfaces->isSoftProofing()); + QCOMPARE(surfaces->softProofProfile(), host.softProofProfileId()); + QTRY_VERIFY_WITH_TIMEOUT(surfaces->diagnosticsForPage(0).has_value() && surfaces->diagnosticsForPage(0)->reasons.contains(QStringLiteral("soft-proof/simulation")), + 30000); + QVERIFY(!host.pageFidelityIsExact()); + + // The indicator says simulation, and the origin names the proofing path rather + // than the fast canvas or the output-preview render. + QCOMPARE(host.previewFidelityStateName(), QStringLiteral("soft-proof")); + QCOMPARE(host.previewFidelityOriginName(), QStringLiteral("soft-proof")); + const QVariantMap visual = host.previewFidelityVisual(); + QVERIFY(visual.value(QStringLiteral("kind")).toString() != QStringLiteral("Passed")); + QVERIFY(visual.value(QStringLiteral("colorRole")).toString() != QStringLiteral("Success")); + + // Never approval evidence: no correction preview, no sign-off, no comparison. + QVERIFY(!host.fixPreview().value(QStringLiteral("available")).toBool()); + QVERIFY(host.fixSignOff().value(QStringLiteral("planDigest")).toString().isEmpty()); + + // Turning it off restores the unproofed identity. + QVERIFY(!host.toggleSoftProof()); + QCOMPARE(host.softProofStateName(), QStringLiteral("off")); + QVERIFY(!host.softProofActive()); + QVERIFY(!surfaces->isSoftProofing()); + QVERIFY(host.softProofProfileId().isEmpty()); + QVERIFY(!host.previewFidelityStateName().contains(QStringLiteral("soft-proof"))); +} + +void EditorHostTest::softProofWithoutAUsableOutputIntentIsAVisibleState() +{ + // A document that names no output intent at all. + { + const QString path = preflightFixturesDir() + QStringLiteral("/output-intent-missing.pdf"); + QVERIFY2(QFileInfo::exists(path), qPrintable(path)); + + EditorHost host; + host.openFileUrl(QUrl::fromLocalFile(path)); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 30000); + host.setViewportGeometry(96.0 / 25.4, 1.0, 1024, 768); + QTRY_COMPARE_WITH_TIMEOUT(host.previewFidelityStateName(), QStringLiteral("exact"), 30000); + + QVERIFY(!host.setSoftProofEnabled(true)); + QCOMPARE(host.softProofStateName(), QStringLiteral("profile-missing")); + QVERIFY(!host.softProofActive()); + QVERIFY(host.softProofProfileId().isEmpty()); + QVERIFY(host.softProofSummary().contains(QStringLiteral("no output-intent profile"))); + + // Never a silent sRGB fallback: the surfaces keep the unproofed identity, and + // the indicator still describes the render path, not a simulation. + QVERIFY(!host.sessionForTest()->surfaces()->isSoftProofing()); + QVERIFY(host.previewFidelityStateName() != QStringLiteral("soft-proof")); + QCOMPARE(host.previewFidelityOriginName(), QStringLiteral("fast-canvas")); + } + + // A document that names one Core cannot read. + { + const QString path = preflightFixturesDir() + QStringLiteral("/output-intent-profile-invalid.pdf"); + QVERIFY2(QFileInfo::exists(path), qPrintable(path)); + + EditorHost host; + host.openFileUrl(QUrl::fromLocalFile(path)); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 30000); + + QVERIFY(!host.setSoftProofEnabled(true)); + QCOMPARE(host.softProofStateName(), QStringLiteral("profile-invalid")); + QVERIFY(!host.softProofActive()); + QVERIFY(host.softProofSummary().contains(QStringLiteral("could not be read"))); + QVERIFY(!host.sessionForTest()->surfaces()->isSoftProofing()); + } +} + +/// Issue #237. The plates are Core's own separations for the document, and the +/// operator's visibility and isolation over them is presentation state: the document +/// revision does not move. +void EditorHostTest::platesListSeparationsAndCarryVisibilityWithoutTouchingTheDocument() +{ + const QString path = preflightFixturesDir() + QStringLiteral("/color-inventory-spot.pdf"); + QVERIFY2(QFileInfo::exists(path), qPrintable(path)); + + EditorHost host; + host.openFileUrl(QUrl::fromLocalFile(path)); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 30000); + + const QVariantList plates = host.productionPlates(); + QVERIFY(plates.size() >= 5); + + QSet ids; + bool sawProcess = false; + bool sawSpot = false; + for (const QVariant& entry : plates) + { + const QVariantMap row = entry.toMap(); + const QString id = row.value(QStringLiteral("id")).toString(); + QVERIFY(!id.isEmpty()); + QVERIFY(!row.value(QStringLiteral("displayName")).toString().isEmpty()); + QVERIFY(row.value(QStringLiteral("visible")).toBool()); + ids.insert(id); + if (row.value(QStringLiteral("kind")).toString() == QStringLiteral("spot")) + { + sawSpot = true; + QCOMPARE(row.value(QStringLiteral("spotColorName")).toString(), id); + } + else + { + sawProcess = true; + } + } + QVERIFY(sawProcess); + QVERIFY(sawSpot); + QVERIFY(ids.contains(QStringLiteral("Cyan"))); + QVERIFY(ids.contains(QStringLiteral("Black"))); + QVERIFY(ids.contains(QStringLiteral("PANTONE 185 C"))); + QVERIFY(host.plateIsolationSummary().contains(QStringLiteral("Every plate is shown"))); + + const QString revisionBefore = host.sessionForTest()->facade().currentRevision().toString(); + const QString spotId = QStringLiteral("PANTONE 185 C"); + + const auto rowFor = [&host](const QString& id) + { + for (const QVariant& entry : host.productionPlates()) + { + const QVariantMap row = entry.toMap(); + if (row.value(QStringLiteral("id")).toString() == id) + { + return row; + } + } + return QVariantMap(); + }; + + // Hiding a plate is presentation state, and it is visible in the summary. + QVERIFY(host.setPlateVisible(spotId, false)); + QVERIFY(!rowFor(spotId).value(QStringLiteral("visible")).toBool()); + QVERIFY(host.plateIsolationSummary().contains(QStringLiteral("Hidden plates"))); + QVERIFY(host.plateIsolationSummary().contains(spotId)); + + // Isolating one shows exactly one. + QVERIFY(host.isolatePlate(QStringLiteral("Cyan"))); + for (const QVariant& entry : host.productionPlates()) + { + const QVariantMap row = entry.toMap(); + QCOMPARE(row.value(QStringLiteral("visible")).toBool(), + row.value(QStringLiteral("id")).toString() == QStringLiteral("Cyan")); + QCOMPARE(row.value(QStringLiteral("isolated")).toBool(), + row.value(QStringLiteral("id")).toString() == QStringLiteral("Cyan")); + } + QVERIFY(host.plateIsolationSummary().contains(QStringLiteral("Isolated plate: Cyan"))); + + QVERIFY(host.showAllPlates()); + QVERIFY(host.plateIsolationSummary().contains(QStringLiteral("Every plate is shown"))); + + // An unknown plate id is refused rather than inventing a plate. + QVERIFY(!host.setPlateVisible(QStringLiteral("No Such Ink"), false)); + QVERIFY(!host.isolatePlate(QStringLiteral("No Such Ink"))); + + // Presentation state: the document revision never moved. + QCOMPARE(host.sessionForTest()->facade().currentRevision().toString(), revisionBefore); +} + +/// Issue #237. The readout is measured through Core's separation-accurate path, and a +/// readout that could not be produced is a visible reason -- never a zero, which would +/// read as "no ink here". +void EditorHostTest::inkCoverageReadoutAtAPointIsMeasuredOrExplicitlyUnavailable() +{ + const QString path = preflightFixturesDir() + QStringLiteral("/color-cmyk.pdf"); + QVERIFY2(QFileInfo::exists(path), qPrintable(path)); + + EditorHost host; + host.openFileUrl(QUrl::fromLocalFile(path)); + QTRY_VERIFY_WITH_TIMEOUT(host.hasDocument(), 30000); + + // A point on the current page is only meaningful once the shell has a layout. + host.setViewportGeometry(96.0 / 25.4, 1.0, 1024, 768); + QTRY_COMPARE_WITH_TIMEOUT(host.currentPage(), 0, 30000); + + // Nothing has been measured yet. + QVERIFY(!host.inkCoverageReadout().value(QStringLiteral("available")).toBool()); + + // Inside the page: a real measurement with per-plate channels. The channel count + // is whatever the page's separated device space has -- three for this fixture -- + // so the assertion is on the shape of the answer, not on a number: named plates, + // coverage in [0, 1] each, and a total that is their sum. + QVERIFY(host.requestInkCoverageAtPoint(150.0, 150.0)); + QTRY_VERIFY_WITH_TIMEOUT(host.inkCoverageReadout().value(QStringLiteral("available")).toBool(), 30000); + const QVariantMap readout = host.inkCoverageReadout(); + QCOMPARE(readout.value(QStringLiteral("pageIndex")).toInt(), 0); + const int channelCount = readout.value(QStringLiteral("channelCount")).toInt(); + QVERIFY2(channelCount >= 1, + qPrintable(QStringLiteral("channelCount=%1").arg(channelCount))); + const QVariantList channels = readout.value(QStringLiteral("channels")).toList(); + QCOMPARE(channels.size(), channelCount); + + double channelSum = 0.0; + for (const QVariant& entry : channels) + { + const QVariantMap channel = entry.toMap(); + QVERIFY(!channel.value(QStringLiteral("name")).toString().isEmpty()); + QVERIFY(!channel.value(QStringLiteral("displayName")).toString().isEmpty()); + const double coverage = channel.value(QStringLiteral("coverage")).toDouble(); + QVERIFY(coverage >= 0.0 && coverage <= 1.0); + channelSum += coverage; + } + + const double total = readout.value(QStringLiteral("totalInkCoverage")).toDouble(); + QVERIFY(qAbs(total - channelSum) < 1e-6); + // The sampled point is inside the fixture's ink, so the readout is a measurement + // rather than a plausible zero. + QVERIFY(total > 0.0); + QVERIFY(readout.value(QStringLiteral("reason")).toString().isEmpty()); + + // Outside the page: a typed reason and available=false, not a zero readout. + QVERIFY(host.requestInkCoverageAtPoint(-100.0, -100.0)); + QTRY_COMPARE_WITH_TIMEOUT(host.inkCoverageReadout().value(QStringLiteral("reason")).toString(), + QStringLiteral("ink-coverage/point-outside-page"), 30000); + QVERIFY(!host.inkCoverageReadout().value(QStringLiteral("available")).toBool()); + QVERIFY(!host.inkCoverageReadout().contains(QStringLiteral("totalInkCoverage"))); + + // No document: the request is refused outright. + EditorHost empty; + QVERIFY(!empty.requestInkCoverageAtPoint(10.0, 10.0)); +} + +QTEST_GUILESS_MAIN(EditorHostTest) #include "tst_editorhosttest.moc" diff --git a/UnitTests/tst_loopstatevisualtest.cpp b/UnitTests/tst_loopstatevisualtest.cpp index 007f797d2..fd460e1f0 100644 --- a/UnitTests/tst_loopstatevisualtest.cpp +++ b/UnitTests/tst_loopstatevisualtest.cpp @@ -241,6 +241,7 @@ private slots: void previewFidelityClassification_data(); void previewFidelityClassification(); + void previewFidelitySimulationOutranksTheRender(); void previewFidelityOrigin_data(); void previewFidelityOrigin(); void previewFidelityStatesStayDistinctWithoutColour(); @@ -838,6 +839,32 @@ void LoopStateVisualTest::previewFidelityClassification() QCOMPARE(classifyPreviewFidelityState(hasDocument, stale, authoritative, exact), expectedName); } +/// A display simulation is what the operator is looking at, so it outranks the +/// render path underneath it (issue #235 display half, issue #237). Only a stale +/// preview, which is not evidence for this revision at all, outranks it in turn. +void LoopStateVisualTest::previewFidelitySimulationOutranksTheRender() +{ + QCOMPARE(classifyPreviewFidelityState(true, false, true, true, QStringLiteral("soft-proof")), + QStringLiteral("soft-proof")); + QCOMPARE(classifyPreviewFidelityState(true, false, true, true, QStringLiteral("display-filter")), + QStringLiteral("display-filter")); + + // Case is the host's, but the classifier normalizes so the state name it returns + // is always the one the visual resolver and the origin table know. + QCOMPARE(classifyPreviewFidelityState(true, false, false, true, QStringLiteral(" Soft-Proof ")), + QStringLiteral("soft-proof")); + + // A preview that does not describe the open revision is not evidence first. + QCOMPARE(classifyPreviewFidelityState(true, true, true, true, QStringLiteral("soft-proof")), + QStringLiteral("stale")); + QCOMPARE(classifyPreviewFidelityState(false, false, true, true, QStringLiteral("soft-proof")), + QStringLiteral("unavailable")); + + // No simulation in force: the render path's own fidelity is the answer. + QCOMPARE(classifyPreviewFidelityState(true, false, false, true, QString()), + QStringLiteral("exact")); +} + void LoopStateVisualTest::previewFidelityOrigin_data() { QTest::addColumn("stateName"); @@ -849,6 +876,8 @@ void LoopStateVisualTest::previewFidelityOrigin_data() QTest::newRow("stale") << QStringLiteral("stale") << QStringLiteral("none"); QTest::newRow("unavailable") << QStringLiteral("unavailable") << QStringLiteral("none"); QTest::newRow("unrecognised") << QStringLiteral("garbage") << QStringLiteral("none"); + QTest::newRow("display filter") << QStringLiteral("display-filter") << QStringLiteral("display-filter"); + QTest::newRow("soft proof") << QStringLiteral("soft-proof") << QStringLiteral("soft-proof"); } void LoopStateVisualTest::previewFidelityOrigin() @@ -866,7 +895,8 @@ void LoopStateVisualTest::previewFidelityStatesStayDistinctWithoutColour() { const QStringList states = { QStringLiteral("unavailable"), QStringLiteral("stale"), QStringLiteral("exact"), QStringLiteral("approximate"), - QStringLiteral("authoritative") }; + QStringLiteral("authoritative"), QStringLiteral("display-filter"), + QStringLiteral("soft-proof") }; QSet icons; QSet names; @@ -888,7 +918,8 @@ void LoopStateVisualTest::previewFidelityNeverClaimsAPass_data() for (const QString& state : { QStringLiteral("unavailable"), QStringLiteral("stale"), QStringLiteral("exact"), QStringLiteral("approximate"), - QStringLiteral("authoritative"), QString() }) + QStringLiteral("authoritative"), QStringLiteral("display-filter"), + QStringLiteral("soft-proof"), QString() }) { QTest::newRow(qPrintable(state.isEmpty() ? QStringLiteral("empty") : state)) << state; } diff --git a/UnitTests/tst_quickcanvastest.cpp b/UnitTests/tst_quickcanvastest.cpp index d1315034b..8f07f1bfd 100644 --- a/UnitTests/tst_quickcanvastest.cpp +++ b/UnitTests/tst_quickcanvastest.cpp @@ -56,6 +56,7 @@ #include "canvaspalette.h" #include "canvaspresentmetrics.h" +#include "canvasdisplaycolor.h" #include "canvastraceoverlay.h" #include "loopcanvasitem.h" @@ -297,6 +298,10 @@ private Q_SLOTS: void zoomReissuesSurfaceDemand(); void firstViewIsUnavailableUntilAPageIsOnScreen(); + void displayColorModesFilterTheCanvasWithoutARender(); + void displayColorModeNamesRoundTripAndUnknownNamesAreRefused(); + void displayColorModesAreDistinctPixelTransforms(); + private: void bindItem(); void bindItemWithSurfaces(); @@ -317,7 +322,12 @@ private Q_SLOTS: /// software and native smoke runs are for -- but it is exactly what these /// assertions need, because they are about node and texture lifetime rather /// than about which backend drew them. - void showItemInWindow(); + /// + /// `tileFill`, when valid, is what the fake renderer paints the page with. It + /// is applied before the window is shown, because publishing the item's + /// geometry is what makes the coordinator request its first surfaces: a fill + /// set after the expose would arrive after the tile was already rendered. + void showItemInWindow(const QColor& tileFill = QColor()); /// Submits surface demand and drains the coordinator relay until at least one /// surface is admitted. The relay is always queued, even for inline work. @@ -420,10 +430,15 @@ void QuickCanvasTest::buildCoordinator() m_surfaces->setDocumentKey(QStringLiteral("doc-1")); } -void QuickCanvasTest::showItemInWindow() +void QuickCanvasTest::showItemInWindow(const QColor& tileFill) { buildCoordinator(); + if (tileFill.isValid()) + { + m_renderer->fill = tileFill; + } + m_window = std::make_unique(); m_window->resize(400, 400); @@ -1305,6 +1320,162 @@ void QuickCanvasTest::firstViewIsUnavailableUntilAPageIsOnScreen() QVERIFY(after.value(QStringLiteral("ms")).toDouble(-1.0) >= 0.0); } -QTEST_MAIN(QuickCanvasTest) +void QuickCanvasTest::displayColorModesFilterTheCanvasWithoutARender() +{ + const QColor tileColor(30, 90, 210); + showItemInWindow(tileColor); + requestSurfacesAndDrain(); + + const int rendersBefore = m_renderer->renderCount; + const int requestedBefore = m_surfaces->counters().requested; + + const QRect placed = m_viewport->placedPageRect(0); + QVERIFY(!placed.isEmpty()); + const QPoint sample = placed.center(); + + const QImage normal = renderFrame(); + QVERIFY(!normal.isNull()); + QCOMPARE(normal.pixelColor(sample), tileColor); + + // The mode is set on the item the way the host sets it: by name, parsed at the + // boundary. The pixels change; nothing is rendered, and nothing is asked of the + // coordinator -- a display colour mode is a presentation filter (issue #235 + // display half), and the failure case it must not have is a page render. + QVERIFY(m_item->setDisplayColorMode(QStringLiteral("invert"))); + const QImage inverted = renderFrame(); + QCOMPARE(inverted.pixelColor(sample), QColor(255 - tileColor.red(), 255 - tileColor.green(), 255 - tileColor.blue())); + + QCOMPARE(m_renderer->renderCount, rendersBefore); + QCOMPARE(m_surfaces->counters().requested, requestedBefore); + QVERIFY(m_item->frameStats().tiles > 0); + QCOMPARE(m_item->frameStats().refusedStaleFrames, 0); + + // Leaving the mode restores the render path's own pixels, from the same + // retained surface: the filter never replaced them. + QVERIFY(m_item->setDisplayColorMode(QStringLiteral("normal"))); + const QImage restored = renderFrame(); + QCOMPARE(restored.pixelColor(sample), tileColor); + QCOMPARE(m_renderer->renderCount, rendersBefore); +} + +void QuickCanvasTest::displayColorModeNamesRoundTripAndUnknownNamesAreRefused() +{ + const QList modes = { + pdfquick::CanvasDisplayColorMode::Normal, + pdfquick::CanvasDisplayColorMode::Invert, + pdfquick::CanvasDisplayColorMode::Grayscale, + pdfquick::CanvasDisplayColorMode::HighContrast, + pdfquick::CanvasDisplayColorMode::Bitonal, + pdfquick::CanvasDisplayColorMode::Custom, + }; + + QSet names; + for (const pdfquick::CanvasDisplayColorMode mode : modes) + { + const QString name = pdfquick::getCanvasDisplayColorModeName(mode); + names.insert(name); + const std::optional parsed = + pdfquick::canvasDisplayColorModeFromName(name); + QVERIFY(parsed.has_value()); + QCOMPARE(*parsed, mode); + } + QCOMPARE(names.size(), modes.size()); + + // Every mode but Normal names the shell action that selects it. + for (const pdfquick::CanvasDisplayColorMode mode : modes) + { + const QString actionId = pdfquick::getCanvasDisplayColorModeActionId(mode); + if (mode == pdfquick::CanvasDisplayColorMode::Normal) + { + QVERIFY(actionId.isEmpty()); + } + else + { + QVERIFY(actionId.startsWith(QStringLiteral("actionColor"))); + } + } + + // A name outside the vocabulary is refused and changes nothing. + QVERIFY(!m_item->setDisplayColorMode(QStringLiteral("sepia"))); + QCOMPARE(m_item->displayColorMode(), QStringLiteral("normal")); + QVERIFY(m_item->setDisplayColorMode(QStringLiteral("GRAYSCALE"))); + QCOMPARE(m_item->displayColorMode(), QStringLiteral("grayscale")); + QVERIFY(!m_item->setDisplayColorMode(QString())); + QCOMPARE(m_item->displayColorMode(), QStringLiteral("grayscale")); +} + +void QuickCanvasTest::displayColorModesAreDistinctPixelTransforms() +{ + QImage source(8, 8, QImage::Format_ARGB32_Premultiplied); + source.fill(QColor(30, 90, 210)); + + pdfquick::CanvasDisplayColorSettings settings; + + // Normal is the identity: the transform is a filter, not a re-render. + QCOMPARE(pdfquick::applyCanvasDisplayColorMode(source, settings), source); + settings.mode = pdfquick::CanvasDisplayColorMode::Invert; + const QImage inverted = pdfquick::applyCanvasDisplayColorMode(source, settings); + QCOMPARE(inverted.pixelColor(4, 4), QColor(225, 165, 45)); + + settings.mode = pdfquick::CanvasDisplayColorMode::Grayscale; + const QImage grayscale = pdfquick::applyCanvasDisplayColorMode(source, settings); + const QColor gray = grayscale.pixelColor(4, 4); + QCOMPARE(gray.red(), gray.green()); + QCOMPARE(gray.green(), gray.blue()); + + settings.mode = pdfquick::CanvasDisplayColorMode::Bitonal; + settings.bitonalThreshold = 128; + const QImage bitonal = pdfquick::applyCanvasDisplayColorMode(source, settings); + QSet bitonalColors; + for (int y = 0; y < bitonal.height(); ++y) + { + for (int x = 0; x < bitonal.width(); ++x) + { + bitonalColors.insert(bitonal.pixel(x, y) | 0xff000000u); + } + } + QVERIFY(bitonalColors.size() <= 2); + + settings.mode = pdfquick::CanvasDisplayColorMode::HighContrast; + const QImage highContrast = pdfquick::applyCanvasDisplayColorMode(source, settings); + QVERIFY(highContrast.pixelColor(4, 4) != source.pixelColor(4, 4)); + + settings.mode = pdfquick::CanvasDisplayColorMode::Custom; + settings.customForeground = QColor(10, 20, 30); + settings.customBackground = QColor(200, 210, 220); + const QImage custom = pdfquick::applyCanvasDisplayColorMode(source, settings); + const QColor customPixel = custom.pixelColor(4, 4); + QVERIFY(customPixel != source.pixelColor(4, 4)); + + // Custom mode maps the source onto the configured foreground/background scale + // rather than replacing every pixel with one of the two: a mid-tone stays + // between them, and the two extremes are exactly the configured colours. + const auto between = [](int value, int first, int second) + { return value >= qMin(first, second) && value <= qMax(first, second); }; + QVERIFY(between(customPixel.red(), settings.customForeground.red(), settings.customBackground.red())); + QVERIFY(between(customPixel.green(), settings.customForeground.green(), settings.customBackground.green())); + QVERIFY(between(customPixel.blue(), settings.customForeground.blue(), settings.customBackground.blue())); + + QImage blackSource(2, 2, QImage::Format_ARGB32_Premultiplied); + blackSource.fill(Qt::black); + QCOMPARE(pdfquick::applyCanvasDisplayColorMode(blackSource, settings).pixelColor(0, 0), settings.customForeground); + + QImage whiteSource(2, 2, QImage::Format_ARGB32_Premultiplied); + whiteSource.fill(Qt::white); + QCOMPARE(pdfquick::applyCanvasDisplayColorMode(whiteSource, settings).pixelColor(0, 0), settings.customBackground); + + // Every active mode produces its own pixels for the same source, so no two + // modes can be mistaken for one another by their output. + const QList outputs = { inverted, grayscale, bitonal, highContrast, custom }; + for (int first = 0; first < outputs.size(); ++first) + { + for (int second = first + 1; second < outputs.size(); ++second) + { + QVERIFY(outputs.at(first) != outputs.at(second)); + } + } +} + +QTEST_MAIN(QuickCanvasTest) #include "tst_quickcanvastest.moc" diff --git a/docs/loop-shell-actions.json b/docs/loop-shell-actions.json index 388e99749..b8712fa7a 100644 --- a/docs/loop-shell-actions.json +++ b/docs/loop-shell-actions.json @@ -179,9 +179,9 @@ "command": { "label_key": "command.actionColorBitonal.label", "parameters": [], - "capability": "unclassified", + "capability": "none", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Production" }, @@ -192,9 +192,9 @@ "command": { "label_key": "command.actionColorCustom.label", "parameters": [], - "capability": "unclassified", + "capability": "none", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Production" }, @@ -205,9 +205,9 @@ "command": { "label_key": "command.actionColorGrayscale.label", "parameters": [], - "capability": "unclassified", + "capability": "none", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Production" }, @@ -218,9 +218,9 @@ "command": { "label_key": "command.actionColorHighContrast.label", "parameters": [], - "capability": "unclassified", + "capability": "none", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Production" }, @@ -231,9 +231,9 @@ "command": { "label_key": "command.actionColorInvert.label", "parameters": [], - "capability": "unclassified", + "capability": "none", "cancellable": false, - "availability": "declared" + "availability": "implemented" }, "menu_group": "Production" }, diff --git a/docs/shell-handler-coverage.json b/docs/shell-handler-coverage.json index 4e6ef8475..d23668efc 100644 --- a/docs/shell-handler-coverage.json +++ b/docs/shell-handler-coverage.json @@ -11,11 +11,7 @@ "actionBookmarkImport", "actionBookmarkPage", "actionClearRecentFileHistory", - "actionColorBitonal", - "actionColorCustom", - "actionColorGrayscale", - "actionColorHighContrast", - "actionColorInvert", + "actionCopyText", "actionCreateBitonalDocument", "actionCreateEllipse", "actionCreateFreehandCurve", @@ -37,8 +33,12 @@ "actionCreateTextSquiggly", "actionCreateTextStrikeout", "actionCreateTextUnderline", + "actionDeselectText", "actionEncryption", + "actionExtractImage", "actionFirstPageOnRightSide", + "actionGoToNextLine", + "actionGoToPreviousLine", "actionGotoNextBookmark", "actionGotoPreviousBookmark", "actionInlineText", @@ -56,6 +56,10 @@ "actionRenderOptionTextAntialiasing", "actionRender_to_Images", "actionSanitize", + "actionScreenshot", + "actionSelectTable", + "actionSelectText", + "actionSelectTextAll", "actionSend_by_E_Mail", "actionStickyNoteComment", "actionStickyNoteHelp", diff --git a/scripts/verify-command-catalog.py b/scripts/verify-command-catalog.py index 7347d8904..26743a8e3 100644 --- a/scripts/verify-command-catalog.py +++ b/scripts/verify-command-catalog.py @@ -93,6 +93,11 @@ "actionGoToPreviousLine", "actionScreenshot", "actionExtractImage", + "actionColorInvert", + "actionColorGrayscale", + "actionColorHighContrast", + "actionColorBitonal", + "actionColorCustom", } ) diff --git a/tools/ProductQuickAccessibilitySmoke/qml/ProductionPreviewPane.qml b/tools/ProductQuickAccessibilitySmoke/qml/ProductionPreviewPane.qml index 58e7fd2e4..9c9af0178 100644 --- a/tools/ProductQuickAccessibilitySmoke/qml/ProductionPreviewPane.qml +++ b/tools/ProductQuickAccessibilitySmoke/qml/ProductionPreviewPane.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Controls +import QtQuick.Dialogs import QtQuick.Layouts // Production Preview workspace (#586): the identity-bound before/after preview. @@ -138,39 +139,227 @@ Pane { Accessible.description: qsTr("Re-renders the current page only; the document stays open.") } } + + Label { + objectName: "productionPreviewSoftProofState" + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: !root.host + ? "" + : qsTr("Soft proof %1: %2") + .arg(root.host.softProofStateName) + .arg(root.host.softProofSummary()) + Accessible.name: qsTr("Soft proof state") + Accessible.description: text + } + + Label { + objectName: "productionPreviewSoftProofProfile" + Layout.fillWidth: true + wrapMode: Text.WordWrap + visible: root.host !== null && root.host.softProofProfileId.length > 0 + text: root.host + ? qsTr("Output-intent profile %1 (identifier %2, type %3).") + .arg(root.host.softProofProfileName) + .arg(root.host.softProofProfileId) + .arg(root.host.softProofProfileTypeName) + : "" + Accessible.name: qsTr("Soft-proof profile identity") + Accessible.description: text + } + + RowLayout { + Layout.fillWidth: true + + Button { + objectName: "productionPreviewSoftProofToggle" + text: root.host && root.host.softProofActive + ? qsTr("Stop the soft proof") + : qsTr("Simulate the output intent (soft proof)") + enabled: root.host && root.host.hasDocument + onClicked: if (root.host) root.host.toggleSoftProof() + Accessible.name: qsTr("Toggle the soft-proof simulation of the output intent") + Accessible.description: qsTr("Renders the canvas through Core's colour management under proofing settings. A simulation of print, never recorded as approval.") + } + } } } + GroupBox { + Layout.fillWidth: true + title: qsTr("Display colour") + Accessible.name: qsTr("Display colour modes") + + ColumnLayout { + anchors.fill: parent + spacing: 4 + + ComboBox { + objectName: "productionPreviewDisplayColorMode" + Layout.fillWidth: true + enabled: root.host !== null + model: [ "normal", "invert", "grayscale", "high-contrast", "bitonal", "custom" ] + currentIndex: { + if (!root.host) + return 0 + const index = model.indexOf(root.host.displayColorModeName) + return index < 0 ? 0 : index + } + onActivated: if (root.host) root.host.setDisplayColorMode(model[currentIndex]) + Accessible.name: qsTr("Display colour mode") + Accessible.description: qsTr("Presentation only: filters the canvas pixels and asks for no render.") + } + + Label { + objectName: "productionPreviewDisplayColorState" + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: !root.host || !root.host.displayColorModeActive + ? qsTr("No display colour mode is active.") + : qsTr("Display colour simulation (%1): presentation only, never a production render.") + .arg(root.host.displayColorModeName) + Accessible.name: qsTr("Display colour state") + Accessible.description: text + } + + RowLayout { + Layout.fillWidth: true + + Button { + objectName: "productionPreviewCustomForeground" + text: qsTr("Custom foreground") + enabled: root.host !== null + onClicked: customForegroundDialog.open() + Accessible.name: qsTr("Choose the custom mode's foreground colour") + } + + Button { + objectName: "productionPreviewCustomBackground" + text: qsTr("Custom background") + enabled: root.host !== null + onClicked: customBackgroundDialog.open() + Accessible.name: qsTr("Choose the custom mode's background colour") + } + } + } + } + + ColorDialog { + id: customForegroundDialog + title: qsTr("Custom mode foreground") + selectedColor: root.host ? root.host.displayColorCustomForeground : "black" + onAccepted: if (root.host) root.host.setDisplayColorCustomColors(selectedColor, root.host.displayColorCustomBackground) + } + + ColorDialog { + id: customBackgroundDialog + title: qsTr("Custom mode background") + selectedColor: root.host ? root.host.displayColorCustomBackground : "white" + onAccepted: if (root.host) root.host.setDisplayColorCustomColors(root.host.displayColorCustomForeground, selectedColor) + } + GroupBox { Layout.fillWidth: true Layout.fillHeight: true title: qsTr("Plates and separations") Accessible.name: qsTr("Plates and separations") - ListView { - id: platesView - objectName: "productionPreviewPlatesView" + ColumnLayout { anchors.fill: parent - clip: true - focus: visible - activeFocusOnTab: true - model: root.production - - Accessible.role: Accessible.List - Accessible.name: qsTr("Plates") - Accessible.description: qsTr("Inks and plates for the current page.") - - delegate: ItemDelegate { - id: plateRow - width: platesView.width - text: plateRow.displayName - Accessible.role: Accessible.ListItem - Accessible.name: plateRow.displayName - Accessible.description: qsTr("Kind %1. Spot colour %2. Overprint %3. Prints %4.") - .arg(plateRow.kind) - .arg(plateRow.spotColorName) - .arg(plateRow.overprint ? qsTr("yes") : qsTr("no")) - .arg(plateRow.shouldPrint ? qsTr("yes") : qsTr("no")) + spacing: 4 + + Label { + objectName: "productionPreviewPlateIsolation" + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: root.host ? root.host.plateIsolationSummary : "" + Accessible.name: qsTr("Plate isolation state") + Accessible.description: text + } + + ListView { + id: platesView + objectName: "productionPreviewPlatesView" + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + focus: visible + activeFocusOnTab: true + model: root.host ? root.host.productionPlates : [] + + Accessible.role: Accessible.List + Accessible.name: qsTr("Plates") + Accessible.description: qsTr("Inks and plates for the current page. Each plate can be shown alone or hidden.") + + delegate: ItemDelegate { + id: plateRow + width: platesView.width + text: plateRow.modelData ? plateRow.modelData.displayName : "" + Accessible.role: Accessible.ListItem + Accessible.name: plateRow.modelData ? plateRow.modelData.displayName : "" + Accessible.description: qsTr("Kind %1. Spot colour %2. Shown %3. Isolated %4.") + .arg(plateRow.modelData ? plateRow.modelData.kind : "") + .arg(plateRow.modelData ? plateRow.modelData.spotColorName : "") + .arg(plateRow.modelData && plateRow.modelData.visible ? qsTr("yes") : qsTr("no")) + .arg(plateRow.modelData && plateRow.modelData.isolated ? qsTr("yes") : qsTr("no")) + + CheckBox { + objectName: "productionPreviewPlateVisibleCheck" + anchors.verticalCenter: parent.verticalCenter + checked: plateRow.modelData ? plateRow.modelData.visible : false + onToggled: if (root.host && plateRow.modelData) root.host.setPlateVisible(plateRow.modelData.id, checked) + Accessible.name: qsTr("Show plate %1").arg(plateRow.modelData ? plateRow.modelData.displayName : "") + } + + Button { + objectName: "productionPreviewPlateIsolate" + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: qsTr("Isolate") + onClicked: if (root.host && plateRow.modelData) root.host.isolatePlate(plateRow.modelData.id) + Accessible.name: qsTr("Show only plate %1").arg(plateRow.modelData ? plateRow.modelData.displayName : "") + } + } + } + + RowLayout { + Layout.fillWidth: true + + Button { + objectName: "productionPreviewShowAllPlates" + text: qsTr("Show all plates") + enabled: root.host !== null + onClicked: if (root.host) root.host.showAllPlates() + Accessible.name: qsTr("Show every plate") + } + + Button { + objectName: "productionPreviewInkCoverage" + text: qsTr("Measure ink at the page centre") + enabled: root.host && root.host.hasDocument + onClicked: if (root.host) root.host.requestInkCoverageAtPoint(root.host.currentPageCentreX, root.host.currentPageCentreY) + Accessible.name: qsTr("Measure ink coverage at the page centre") + Accessible.description: qsTr("Renders the current page through Core's separation-accurate path and reports the ink coverage at one point.") + } + } + + Label { + objectName: "productionPreviewInkCoverageReadout" + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: { + if (!root.host || !root.host.inkCoverageReadout || !root.host.inkCoverageReadout.available) + return root.host && root.host.inkCoverageReadout && root.host.inkCoverageReadout.reason + ? qsTr("Ink coverage is not measured: %1.").arg(root.host.inkCoverageReadout.reason) + : qsTr("Ink coverage has not been measured at a point.") + return qsTr("Ink coverage at (%1, %2) pt: %3 total across %4 plate(s).") + .arg(Number(root.host.inkCoverageReadout.pageX).toFixed(1)) + .arg(Number(root.host.inkCoverageReadout.pageY).toFixed(1)) + .arg(Number(root.host.inkCoverageReadout.totalInkCoverage).toFixed(3)) + .arg(root.host.inkCoverageReadout.channelCount) + } + Accessible.name: qsTr("Ink coverage readout") + Accessible.description: text } } } From c8e83e3fd9b08d8e34824975b2a943784bd1193b Mon Sep 17 00:00:00 2001 From: mberrys <212453881+mberrys@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:29:09 -0700 Subject: [PATCH 06/12] feat(quick): settings, render options and diagnostic views (#239) One typed QSettings-backed store with confirmed reset-to-factory and visible repair of invalid persisted values; actionOptions, actionResetToFactorySettings, actionCollectDiagnostics, the six render-option toggles and the text-block/line views are bound and developer-gated; the ADR-006 diagnostics bundle comes from Core's PDFDiagnosticsCollector. The five KEEP render options are reclassified ADVANCED with rationale and the host routes menus from the manifest's menu_group, so they are reachable in a developer profile only. --- LoopEditor/CMakeLists.txt | 4 + LoopEditor/editorhost.cpp | 212 ++++++++++- LoopEditor/editorhost.h | 71 +++- LoopEditor/editorsettings.cpp | 336 ++++++++++++++++++ LoopEditor/editorsettings.h | 121 +++++++ LoopEditor/qml/DiagnosticsDialog.qml | 84 +++++ LoopEditor/qml/Main.qml | 30 ++ LoopEditor/qml/SettingsDialog.qml | 140 ++++++++ UnitTests/phase4-tests.cmake | 10 + UnitTests/tst_editorhosttest.cpp | 226 ++++++++++++ UnitTests/tst_shellworkspacetest.cpp | 150 ++++++++ docs/loop-shell-actions.json | 54 +-- docs/shell-handler-coverage.json | 5 - .../ci/test_check_preflight_truth_source.py | 4 + scripts/verify-command-catalog.py | 11 + .../CMakeLists.txt | 4 + .../qml/DiagnosticsDialog.qml | 84 +++++ .../qml/Main.qml | 30 ++ .../qml/SettingsDialog.qml | 140 ++++++++ 19 files changed, 1678 insertions(+), 38 deletions(-) create mode 100644 LoopEditor/editorsettings.cpp create mode 100644 LoopEditor/editorsettings.h create mode 100644 LoopEditor/qml/DiagnosticsDialog.qml create mode 100644 LoopEditor/qml/SettingsDialog.qml create mode 100644 tools/ProductQuickAccessibilitySmoke/qml/DiagnosticsDialog.qml create mode 100644 tools/ProductQuickAccessibilitySmoke/qml/SettingsDialog.qml diff --git a/LoopEditor/CMakeLists.txt b/LoopEditor/CMakeLists.txt index c1a4d85d9..a93f8a4d4 100644 --- a/LoopEditor/CMakeLists.txt +++ b/LoopEditor/CMakeLists.txt @@ -32,6 +32,8 @@ qt_add_executable(LoopEditor documentviewsession.h editorhost.cpp editorhost.h + editorsettings.cpp + editorsettings.h focusrestoration.cpp focusrestoration.h quickdocumentmodel.cpp @@ -45,6 +47,8 @@ qt_add_qml_module(LoopEditor QML_FILES qml/Main.qml qml/OperatorHelpDialog.qml + qml/SettingsDialog.qml + qml/DiagnosticsDialog.qml qml/Workspace.qml qml/DocumentPane.qml qml/CanvasPane.qml diff --git a/LoopEditor/editorhost.cpp b/LoopEditor/editorhost.cpp index 27466fa71..b4f07e706 100644 --- a/LoopEditor/editorhost.cpp +++ b/LoopEditor/editorhost.cpp @@ -55,7 +55,9 @@ #include "pdfblockingthreadguard.h" #include "pdfcms.h" +#include "pdfdiagnostics.h" #include "pdfpage.h" +#include "pdfrenderer.h" #include "pdftransparencyrenderer.h" #include @@ -283,6 +285,20 @@ QString shellMenuGroupForAction(const QString& id, const QString& target) return QStringLiteral("Document"); } +pdf::PDFRenderer::Features featuresForOptions(const EditorSettings::RenderOptions& options) +{ + pdf::PDFRenderer::Features features = pdf::PDFRenderer::getDefaultFeatures(); + features.setFlag(pdf::PDFRenderer::Antialiasing, options.antialiasing); + features.setFlag(pdf::PDFRenderer::TextAntialiasing, options.textAntialiasing); + features.setFlag(pdf::PDFRenderer::SmoothImages, options.smoothPictures); + features.setFlag(pdf::PDFRenderer::DisplayAnnotations, options.displayAnnotations); + features.setFlag(pdf::PDFRenderer::IgnoreOptionalContent, options.ignoreOptionalContentSettings); + features.setFlag(pdf::PDFRenderer::DisplayTimes, options.displayRenderTimes); + features.setFlag(pdf::PDFRenderer::DebugTextBlocks, options.showTextBlocks); + features.setFlag(pdf::PDFRenderer::DebugTextLines, options.showTextLines); + return features; +} + QVariantMap descriptorToVariant(const pdfinteraction::CommandDescriptor& descriptor, bool enabled) { QVariantMap entry; @@ -292,7 +308,11 @@ QVariantMap descriptorToVariant(const pdfinteraction::CommandDescriptor& descrip entry.insert(QStringLiteral("enabled"), enabled); entry.insert(QStringLiteral("target"), descriptor.target); entry.insert(QStringLiteral("disposition"), descriptor.disposition); - entry.insert(QStringLiteral("menuGroup"), shellMenuGroupForAction(descriptor.id, descriptor.target)); + // Menu placement is the manifest's routing policy (menu_group); the prefix + // table is only the fallback for a descriptor that arrives without one. + entry.insert(QStringLiteral("menuGroup"), + descriptor.menuGroup.isEmpty() ? shellMenuGroupForAction(descriptor.id, descriptor.target) + : descriptor.menuGroup); QVariantMap shortcut; shortcut.insert(QStringLiteral("standardKey"), descriptor.shortcut.standardKey); @@ -314,7 +334,7 @@ struct EditorHost::PreflightWorkerOutcome QJsonObject auditSummary; }; -EditorHost::EditorHost(QObject* parent) : +EditorHost::EditorHost(EditorSettings* settings, QObject* parent) : QObject(parent), m_session(std::make_unique(this)), m_findingNavigator(std::make_unique( @@ -327,6 +347,16 @@ EditorHost::EditorHost(QObject* parent) : m_preflight(&m_session->scheduler(), this), m_actionListController(&m_session->scheduler(), this) { + if (settings) + { + m_settings = settings; + } + else + { + m_ownedSettings = std::make_unique(nullptr, this); + m_settings = m_ownedSettings.get(); + } + // Registers this constructing thread -- the one QML dispatches pointer // and frame callbacks on -- as the thread blocking service adapters // (PreflightEngine::run, and future OCR/AI/file-I/O adapters) must @@ -356,6 +386,12 @@ EditorHost::EditorHost(QObject* parent) : registerShellHandlers(); registerFeatureHandlers(); + // One apply path for every option change, wherever it came from: the + // command handlers, the settings surface, or a factory reset. + connect(m_settings, &EditorSettings::optionsChanged, this, [this] + { applyRenderOptions(); }); + connect(m_settings, &EditorSettings::noticeChanged, this, &EditorHost::bumpPresentation); + m_preflightOverlayBridge.setFindingsModel(m_preflight.findingsModel()); m_preflightOverlayBridge.setOverlayBuilder(m_session->overlays()); m_preflightOverlayBridge.setInteractionController(m_session->interaction()); @@ -1656,6 +1692,101 @@ bool EditorHost::allowDeveloperDiagnostics() const #endif } +QString EditorHost::settingsNotice() +{ + return m_settings->notice(); +} + +QVariantMap EditorHost::diagnosticsBundle() const +{ + QVariantMap bundle; + bundle.insert(QStringLiteral("status"), m_diagnosticsBundle.status); + bundle.insert(QStringLiteral("directory"), m_diagnosticsBundle.directory); + bundle.insert(QStringLiteral("error"), m_diagnosticsBundle.error); + bundle.insert(QStringLiteral("fileCount"), m_diagnosticsBundle.fileCount); + return bundle; +} + +bool EditorHost::requestSettingsReset() +{ + m_settingsResetPending = true; + Q_EMIT settingsResetRequested(); + bumpPresentation(); + return true; +} + +bool EditorHost::confirmSettingsReset() +{ + if (!m_settingsResetPending) + { + return false; + } + m_settingsResetPending = false; + + // resetToFactory() reports its own failure through the store notice; the + // typed state is only replaced when every owned key was removed. + const bool reset = m_settings->resetToFactory(); + bumpPresentation(); + return reset; +} + +void EditorHost::cancelSettingsReset() +{ + if (!m_settingsResetPending) + { + return; + } + m_settingsResetPending = false; + bumpPresentation(); +} + +bool EditorHost::collectDiagnosticsFileUrl(const QUrl& parentDirectory) +{ + if (!parentDirectory.isValid() || !parentDirectory.isLocalFile() || parentDirectory.toLocalFile().isEmpty()) + { + m_diagnosticsBundle.status = QStringLiteral("failed"); + m_diagnosticsBundle.directory.clear(); + m_diagnosticsBundle.fileCount = 0; + m_diagnosticsBundle.error = tr("Choose a local folder for the diagnostics bundle."); + bumpPresentation(); + return false; + } + + pdf::PDFDiagnosticsOptions options; + options.applicationId = QStringLiteral("editor"); + options.outputDirectory = parentDirectory.toLocalFile(); + const pdf::PDFDiagnosticsResult result = pdf::PDFDiagnosticsCollector::collect(options); + + if (!result.success) + { + m_diagnosticsBundle.status = QStringLiteral("failed"); + m_diagnosticsBundle.directory.clear(); + m_diagnosticsBundle.fileCount = 0; + m_diagnosticsBundle.error = result.errorMessage.isEmpty() + ? tr("The diagnostics bundle could not be written.") + : result.errorMessage; + bumpPresentation(); + return false; + } + + m_diagnosticsBundle.status = QStringLiteral("ready"); + m_diagnosticsBundle.error.clear(); + m_diagnosticsBundle.directory = result.bundleDirectory; + m_diagnosticsBundle.fileCount = static_cast(result.files.size()); + bumpPresentation(); + return true; +} + +void EditorHost::applyRenderOptions() +{ + m_session->setSurfaceRenderFeatures(featuresForOptions(m_settings->renderOptions())); + if (m_documentBound) + { + m_session->surfaces()->requestSurfaces(); + } + bumpPresentation(); +} + void EditorHost::acknowledgeSearchPanel() { if (!m_searchPanelVisible) @@ -1858,6 +1989,13 @@ bool EditorHost::pageFidelityIsExact() const return true; } + if (m_settings->isOptionEnabled(EditorSettings::IgnoreOptionalContentOptionId)) + { + // The optional-content override deliberately deviates from the + // document's own view state; no render under it is an exact render. + return false; + } + const std::optional diagnostics = m_session->surfaces()->diagnosticsForPage(currentPage()); return !diagnostics.has_value() || diagnostics->isExact(); } @@ -1869,13 +2007,20 @@ QString EditorHost::pageFidelityReason() const return QString(); } + QStringList reasons; + if (m_settings->isOptionEnabled(EditorSettings::IgnoreOptionalContentOptionId)) + { + reasons.append(tr("The optional content override is enabled: this render shows all optional " + "content regardless of the document's settings.")); + } + const std::optional diagnostics = m_session->surfaces()->diagnosticsForPage(currentPage()); - if (!diagnostics.has_value() || diagnostics->reasons.isEmpty()) + if (diagnostics.has_value() && !diagnostics->reasons.isEmpty()) { - return QString(); + reasons.append(diagnostics->reasons); } - return diagnostics->reasons.join(QStringLiteral(" ")); + return reasons.join(QStringLiteral(" ")); } bool EditorHost::pageFidelityIsAuthoritative() const @@ -4473,6 +4618,41 @@ void EditorHost::registerFeatureHandlers() : pdfquick::getCanvasDisplayColorModeName(mode)); }); } + // Shell settings, render options and diagnostics (#239). Every one of these + // is ADVANCED-disposition and developer-gated; binding a handler does not + // make it reachable in a release profile. + bind(QStringLiteral("actionOptions"), [this] + { Q_EMIT settingsPanelRequested(); }); + bind(QStringLiteral("actionResetToFactorySettings"), [this] + { requestSettingsReset(); }); + bind(QStringLiteral("actionCollectDiagnostics"), [this] + { Q_EMIT diagnosticsCollectionRequested(); }); + + auto bindOptionToggle = [this](const QString& commandId, const QString& optionId) + { + pdfinteraction::CommandCatalog::Handler handler; + handler.invoke = [this, optionId](pdfinteraction::CommandInvocationId invocation, const QVariantMap&) + { + const bool enabled = !m_settings->isOptionEnabled(optionId); + const bool applied = m_settings->setOptionEnabled(optionId, enabled); + m_session->catalog().finishInvocation(invocation, + applied ? pdfinteraction::CommandTerminalState::Completed + : pdfinteraction::CommandTerminalState::Failed, + applied ? QString() : QStringLiteral("settings/persist-failed")); + bumpPresentation(); + }; + m_session->catalog().setHandler(commandId, std::move(handler)); + }; + + bindOptionToggle(QStringLiteral("actionRenderOptionAntialiasing"), EditorSettings::AntialiasingOptionId); + bindOptionToggle(QStringLiteral("actionRenderOptionTextAntialiasing"), EditorSettings::TextAntialiasingOptionId); + bindOptionToggle(QStringLiteral("actionRenderOptionSmoothPictures"), EditorSettings::SmoothPicturesOptionId); + bindOptionToggle(QStringLiteral("actionRenderOptionDisplayAnnotations"), EditorSettings::DisplayAnnotationsOptionId); + bindOptionToggle(QStringLiteral("actionRenderOptionIgnoreOptionalContentSettings"), EditorSettings::IgnoreOptionalContentOptionId); + bindOptionToggle(QStringLiteral("actionRenderOptionDisplayRenderTimes"), EditorSettings::DisplayRenderTimesOptionId); + bindOptionToggle(QStringLiteral("actionShow_Text_Blocks"), EditorSettings::ShowTextBlocksOptionId); + bindOptionToggle(QStringLiteral("actionShow_Text_Lines"), EditorSettings::ShowTextLinesOptionId); + pdfinteraction::CommandCatalog::Handler move; move.invoke = [this](pdfinteraction::CommandInvocationId invocation, const QVariantMap& parameters) { @@ -4519,6 +4699,25 @@ void EditorHost::refreshFeatureAvailability() availability.insert(id, ready); } + // The developer-diagnostics shell surfaces (#239): settings, render options + // and the diagnostics bundle. They follow the same flag the menu gate + // reads, so a release profile cannot reach them even by invocation. + const bool developerDiagnostics = allowDeveloperDiagnostics(); + for (const QString& id : { QStringLiteral("actionOptions"), + QStringLiteral("actionResetToFactorySettings"), + QStringLiteral("actionCollectDiagnostics"), + QStringLiteral("actionRenderOptionAntialiasing"), + QStringLiteral("actionRenderOptionTextAntialiasing"), + QStringLiteral("actionRenderOptionSmoothPictures"), + QStringLiteral("actionRenderOptionDisplayAnnotations"), + QStringLiteral("actionRenderOptionIgnoreOptionalContentSettings"), + QStringLiteral("actionRenderOptionDisplayRenderTimes"), + QStringLiteral("actionShow_Text_Blocks"), + QStringLiteral("actionShow_Text_Lines") }) + { + availability.insert(id, developerDiagnostics); + } + m_session->catalog().setEnabledBatch(availability); } @@ -4577,6 +4776,9 @@ void EditorHost::bumpCommandEpoch() void EditorHost::onDocumentReady() { + // The persisted render options must be in force before the first surfaces + // are requested, so no page is rendered twice on open. + applyRenderOptions(); m_session->prepareDocumentView(); syncRevisionModels(); diff --git a/LoopEditor/editorhost.h b/LoopEditor/editorhost.h index 04ee5ddb1..a448f49e1 100644 --- a/LoopEditor/editorhost.h +++ b/LoopEditor/editorhost.h @@ -46,6 +46,7 @@ #include "focusrestoration.h" #include "documentviewsession.h" +#include "editorsettings.h" #include "quickdocumentmodel.h" #include "canvasdisplaycolor.h" @@ -149,6 +150,16 @@ class EditorHost final : public QObject Q_PROPERTY(QString productionStateName READ productionStateName NOTIFY presentationChanged) Q_PROPERTY(bool allowDeveloperDiagnostics READ allowDeveloperDiagnostics CONSTANT) + // ---- Shell settings and diagnostics (#239) ------------------------------------- + // The settings store is the one typed home of the persisted render options; + // QML renders its rows and toggles them through its typed invokables. The + // diagnostics bundle projection names the last collection outcome, never a + // second copy of the bundle's content. + Q_PROPERTY(QObject* settings READ settings CONSTANT) + Q_PROPERTY(QString settingsNotice READ settingsNotice NOTIFY presentationChanged) + Q_PROPERTY(bool settingsResetPending READ settingsResetPending NOTIFY presentationChanged) + Q_PROPERTY(QVariantMap diagnosticsBundle READ diagnosticsBundle NOTIFY presentationChanged) + // ---- Workspace surfaces (#586) ------------------------------------------------- // Every property below is a read-only projection of what Core, PdfTool or the // shell's own run already decided. QML renders these; it derives no state and @@ -212,7 +223,10 @@ class EditorHost final : public QObject }; Q_ENUM(LoopWorkspace) - explicit EditorHost(QObject* parent = nullptr); + /// `settings` is the one typed settings store. When null, the host opens + /// the application's own user-scope store on first use; an injected store + /// stays owned by the caller and must outlive the host. + explicit EditorHost(EditorSettings* settings = nullptr, QObject* parent = nullptr); ~EditorHost() override; EditorHost(const EditorHost&) = delete; @@ -290,6 +304,35 @@ class EditorHost final : public QObject /// Production / plate model for the Pages and Production Preview surfaces. QObject* production() { return &m_production; } + /// The one typed settings store (#239). QML renders its option rows and + /// calls its typed invokables; nothing else owns a settings copy. + QObject* settings() { return m_settings; } + + /// The settings store's visible notice: a persisted value that was rejected + /// and replaced, or the last refused settings operation. Empty when nothing + /// needs reporting. + QString settingsNotice(); + + /// True while a confirmed reset-to-factory is armed and awaiting the + /// operator's confirmation. + bool settingsResetPending() const noexcept { return m_settingsResetPending; } + + /// Outcome of the last diagnostics collection: `status` is `idle`, `ready` + /// or `failed`, with the bundle directory and file count on success and a + /// typed error on failure. + QVariantMap diagnosticsBundle() const; + + /// Arms the reset-to-factory confirmation and opens the settings surface. + /// The reset itself only runs through confirmSettingsReset(). + Q_INVOKABLE bool requestSettingsReset(); + Q_INVOKABLE bool confirmSettingsReset(); + Q_INVOKABLE void cancelSettingsReset(); + + /// Collects the ADR-006 diagnostics bundle under the operator-chosen parent + /// directory and projects the outcome. QML passes a folder URL; paths stay + /// in C++. + Q_INVOKABLE bool collectDiagnosticsFileUrl(const QUrl& parentDirectory); + /// Operator lifecycle of the governed-correction route (#586): /// `idle`, `planned`, `preview-ready`, `approved`, `executing`, `succeeded`, /// `stale`, `rejected`, `cancelled`, `failed`. It is a projection of the run the @@ -598,6 +641,12 @@ class EditorHost final : public QObject /// asks for one and answers with extractCurrentPageImagesFileUrl(). void imageExtractionRequested(); + /// The shell-side surfaces (#239). The commands emit these; QML owns the + /// dialogs and opens them. + void settingsPanelRequested(); + void settingsResetRequested(); + void diagnosticsCollectionRequested(); + private: void connectFacade(); void connectViewport(); @@ -673,6 +722,9 @@ class EditorHost final : public QObject QVariantMap plateRow(const QString& id) const; QPointF currentPageCentre() const; void finishInkCoverageJob(const pdf::PDFJobSnapshot& snapshot); + /// Pushes the typed render options into the shared render presentation + /// policy and re-requests the surfaces whose keys the change superseded. + void applyRenderOptions(); /// Operator review of the planned correction (#586). `fixPlanIsCurrent()` is the only /// authority on whether the reviewed plan still describes the open revision. @@ -699,6 +751,23 @@ class EditorHost final : public QObject FocusRestoration m_focusRestoration; pdfinteraction::FindingListHitTestSource m_findingsHitTest; + /// The settings store: injected by the caller, or owned here when the host + /// was constructed without one. See the constructor's contract. + std::unique_ptr m_ownedSettings; + EditorSettings* m_settings = nullptr; + bool m_settingsResetPending = false; + + /// The last diagnostics collection's outcome. A projection of the + /// collector's own result, never a second copy of the bundle. + struct DiagnosticsBundleState + { + QString status = QStringLiteral("idle"); + QString directory; + QString error; + int fileCount = 0; + }; + DiagnosticsBundleState m_diagnosticsBundle; + QPointer m_canvas; QHash m_activeAsyncJobs; struct PreflightWorkerOutcome; diff --git a/LoopEditor/editorsettings.cpp b/LoopEditor/editorsettings.cpp new file mode 100644 index 000000000..f14e81e03 --- /dev/null +++ b/LoopEditor/editorsettings.cpp @@ -0,0 +1,336 @@ +// MIT License +// Copyright (c) 2026 Loop contributors + +#include "editorsettings.h" + +#include +#include + +#include +#include + +namespace +{ + +const QString RenderOptionsGroup = QStringLiteral("renderOptions"); + +/// Strict parse of one persisted value. QSettings writes booleans as +/// "true"/"false" (or a boolean in the native backend); anything else is an +/// invalid stored value, not a value to coerce. +bool parseStoredBool(const QVariant& stored, bool* parsed) +{ + if (stored.metaType().id() == QMetaType::Bool) + { + *parsed = true; + return stored.toBool(); + } + if (stored.metaType().id() == QMetaType::QString) + { + const QString text = stored.toString().trimmed(); + if (text.compare(QStringLiteral("true"), Qt::CaseInsensitive) == 0) + { + *parsed = true; + return true; + } + if (text.compare(QStringLiteral("false"), Qt::CaseInsensitive) == 0) + { + *parsed = true; + return false; + } + } + + *parsed = false; + return false; +} + +} // namespace + +EditorSettings::EditorSettings(QSettings* settings, QObject* parent) : + QObject(parent), + m_store(settings) +{ + m_options = { + { AntialiasingOptionId, + tr("Antialiasing"), + tr("Smooth the edges of lines, shapes and curves on the page."), + RenderOptionsGroup + QStringLiteral("/antialiasing"), + true, + true }, + { TextAntialiasingOptionId, + tr("Text antialiasing"), + tr("Smooth the rendering of text on the page."), + RenderOptionsGroup + QStringLiteral("/textAntialiasing"), + true, + true }, + { SmoothPicturesOptionId, + tr("Smooth pictures"), + tr("Scale images with smooth interpolation. Slower, but better image quality."), + RenderOptionsGroup + QStringLiteral("/smoothPictures"), + false, + false }, + { DisplayAnnotationsOptionId, + tr("Display annotations"), + tr("Draw the document's annotations over the page."), + RenderOptionsGroup + QStringLiteral("/displayAnnotations"), + true, + true }, + { IgnoreOptionalContentOptionId, + tr("Ignore optional content settings"), + tr("Show all optional content regardless of the document's own layer settings. The " + "preview no longer matches the document's view state; the fidelity indicator " + "reports the deviation."), + RenderOptionsGroup + QStringLiteral("/ignoreOptionalContentSettings"), + false, + false }, + { DisplayRenderTimesOptionId, + tr("Display render times"), + tr("Draw the page compile and draw time over the page."), + RenderOptionsGroup + QStringLiteral("/displayRenderTimes"), + false, + false }, + { ShowTextBlocksOptionId, + tr("Show text blocks"), + tr("Draw the text block layout the renderer parsed."), + RenderOptionsGroup + QStringLiteral("/showTextBlocks"), + false, + false }, + { ShowTextLinesOptionId, + tr("Show text lines"), + tr("Draw the text line layout the renderer parsed."), + RenderOptionsGroup + QStringLiteral("/showTextLines"), + false, + false }, + }; +} + +EditorSettings::~EditorSettings() = default; + +QSettings& EditorSettings::store() +{ + if (!m_store) + { + // The owned fallback: the same IniFormat/UserScope idiom Core uses for + // the diagnostics settings, so a host constructed without an explicit + // store still persists under the application identity. + m_ownedStore = std::make_unique(QSettings::IniFormat, + QSettings::UserScope, + QCoreApplication::organizationName(), + QCoreApplication::applicationName()); + m_store = m_ownedStore.get(); + } + return *m_store; +} + +void EditorSettings::load() +{ + if (m_loaded) + { + return; + } + m_loaded = true; + + QSettings& settings = store(); + for (Option& option : m_options) + { + if (!settings.contains(option.settingsKey)) + { + option.value = option.defaultValue; + continue; + } + + bool parsed = false; + const bool value = parseStoredBool(settings.value(option.settingsKey), &parsed); + if (parsed) + { + option.value = value; + continue; + } + + const QString stored = settings.value(option.settingsKey).toString(); + option.value = option.defaultValue; + m_loadNotices.append(tr("\"%1\" was stored as \"%2\", which is not a boolean; it was replaced by its " + "default (%3).") + .arg(option.settingsKey, + stored, + option.defaultValue ? tr("enabled") : tr("disabled"))); + if (!writeOptionValue(option, option.defaultValue)) + { + m_loadNotices.append(tr("\"%1\" could not be repaired in the settings store.").arg(option.settingsKey)); + } + } + + if (!m_loadNotices.isEmpty()) + { + Q_EMIT noticeChanged(); + } +} + +EditorSettings::Option* EditorSettings::findOption(const QString& id) +{ + const auto it = std::find_if(m_options.begin(), m_options.end(), [&id](const Option& option) + { return option.id == id; }); + return it == m_options.end() ? nullptr : &*it; +} + +bool EditorSettings::writeOptionValue(const Option& option, bool enabled) +{ + QSettings& settings = store(); + settings.setValue(option.settingsKey, enabled); + settings.sync(); + return settings.status() == QSettings::NoError; +} + +void EditorSettings::setOperationNotice(QString text) +{ + if (m_operationNotice == text) + { + return; + } + m_operationNotice = std::move(text); + Q_EMIT noticeChanged(); +} + +EditorSettings::RenderOptions EditorSettings::renderOptions() +{ + load(); + + RenderOptions options; + for (const Option& option : m_options) + { + if (option.id == AntialiasingOptionId) + { + options.antialiasing = option.value; + } + else if (option.id == TextAntialiasingOptionId) + { + options.textAntialiasing = option.value; + } + else if (option.id == SmoothPicturesOptionId) + { + options.smoothPictures = option.value; + } + else if (option.id == DisplayAnnotationsOptionId) + { + options.displayAnnotations = option.value; + } + else if (option.id == IgnoreOptionalContentOptionId) + { + options.ignoreOptionalContentSettings = option.value; + } + else if (option.id == DisplayRenderTimesOptionId) + { + options.displayRenderTimes = option.value; + } + else if (option.id == ShowTextBlocksOptionId) + { + options.showTextBlocks = option.value; + } + else if (option.id == ShowTextLinesOptionId) + { + options.showTextLines = option.value; + } + } + return options; +} + +bool EditorSettings::isOptionEnabled(const QString& id) +{ + load(); + + const Option* option = findOption(id); + return option && option->value; +} + +QVariantList EditorSettings::options() +{ + load(); + + QVariantList rows; + rows.reserve(static_cast(m_options.size())); + for (const Option& option : m_options) + { + QVariantMap row; + row.insert(QStringLiteral("id"), option.id); + row.insert(QStringLiteral("label"), option.label); + row.insert(QStringLiteral("description"), option.description); + row.insert(QStringLiteral("enabled"), option.value); + rows.append(row); + } + return rows; +} + +QString EditorSettings::notice() +{ + load(); + + QStringList notices = m_loadNotices; + if (!m_operationNotice.isEmpty()) + { + notices.append(m_operationNotice); + } + return notices.join(QLatin1Char('\n')); +} + +bool EditorSettings::hasNotice() +{ + return !notice().isEmpty(); +} + +bool EditorSettings::setOptionEnabled(const QString& id, bool enabled) +{ + load(); + + Option* option = findOption(id); + if (!option) + { + setOperationNotice(tr("Unknown setting \"%1\" was refused.").arg(id)); + return false; + } + + if (option->value == enabled) + { + return true; + } + + if (!writeOptionValue(*option, enabled)) + { + setOperationNotice(tr("\"%1\" could not be saved; the previous value is unchanged.").arg(option->label)); + return false; + } + + option->value = enabled; + setOperationNotice(QString()); + Q_EMIT optionsChanged(); + return true; +} + +bool EditorSettings::resetToFactory() +{ + load(); + + QSettings& settings = store(); + settings.remove(RenderOptionsGroup); + settings.sync(); + if (settings.status() != QSettings::NoError) + { + setOperationNotice(tr("The shell settings could not be reset; the stored values are unchanged.")); + return false; + } + + bool changed = false; + for (Option& option : m_options) + { + if (option.value != option.defaultValue) + { + option.value = option.defaultValue; + changed = true; + } + } + + setOperationNotice(tr("Shell settings were reset to factory defaults.")); + if (changed) + { + Q_EMIT optionsChanged(); + } + return true; +} diff --git a/LoopEditor/editorsettings.h b/LoopEditor/editorsettings.h new file mode 100644 index 000000000..42964e163 --- /dev/null +++ b/LoopEditor/editorsettings.h @@ -0,0 +1,121 @@ +// MIT License +// Copyright (c) 2026 Loop contributors + +#ifndef EDITORSETTINGS_H +#define EDITORSETTINGS_H + +#include +#include +#include +#include +#include + +#include +#include + +/// The one typed settings store for the Quick shell (#239). +/// +/// Owns the persisted render options and diagnostic-view toggles the shell +/// applies to the interactive render path, and the factory reset that returns +/// them to their defaults. Persisted values are parsed once, when the store is +/// first read, into typed state: a stored value that is not a boolean is +/// reported through notice() and replaced by its default, never silently +/// coerced. Every option is presentation state only - no option changes a +/// document. +class EditorSettings final : public QObject +{ + Q_OBJECT + Q_PROPERTY(QVariantList options READ options NOTIFY optionsChanged) + Q_PROPERTY(QString notice READ notice NOTIFY noticeChanged) + Q_PROPERTY(bool hasNotice READ hasNotice NOTIFY noticeChanged) + +public: + /// The typed render options, mirroring pdf::PDFRenderer::Features. The + /// defaults reproduce PDFRenderer::getDefaultFeatures() plus the off-by- + /// default diagnostic views. + struct RenderOptions + { + bool antialiasing = true; + bool textAntialiasing = true; + bool smoothPictures = false; + bool displayAnnotations = true; + bool ignoreOptionalContentSettings = false; + bool displayRenderTimes = false; + bool showTextBlocks = false; + bool showTextLines = false; + }; + + /// The option ids the store persists; the settings surface and the command + /// handlers name the same ids. + static inline const QString AntialiasingOptionId = QStringLiteral("antialiasing"); + static inline const QString TextAntialiasingOptionId = QStringLiteral("textAntialiasing"); + static inline const QString SmoothPicturesOptionId = QStringLiteral("smoothPictures"); + static inline const QString DisplayAnnotationsOptionId = QStringLiteral("displayAnnotations"); + static inline const QString IgnoreOptionalContentOptionId = QStringLiteral("ignoreOptionalContentSettings"); + static inline const QString DisplayRenderTimesOptionId = QStringLiteral("displayRenderTimes"); + static inline const QString ShowTextBlocksOptionId = QStringLiteral("showTextBlocks"); + static inline const QString ShowTextLinesOptionId = QStringLiteral("showTextLines"); + + /// The backing store stays with the caller and must outlive this object. A + /// null pointer opens the application's own user-scope settings on first + /// use, which keeps construction free of I/O for a caller that never reads. + explicit EditorSettings(QSettings* settings = nullptr, QObject* parent = nullptr); + ~EditorSettings() override; + + EditorSettings(const EditorSettings&) = delete; + EditorSettings& operator=(const EditorSettings&) = delete; + + /// The typed snapshot, loading the store on first read. + RenderOptions renderOptions(); + + /// True when `id` is enabled in the typed state. An unknown id answers + /// false; it is never defaulted to a value. + bool isOptionEnabled(const QString& id); + + /// Presentation rows for QML: id, label, description, enabled. + QVariantList options(); + + /// Every visible notice: a persisted value that was rejected and replaced, + /// or the last refused operation. Empty when nothing needs reporting. + QString notice(); + bool hasNotice(); + + /// Persists one option. An unknown id is refused. A failed write leaves + /// both the typed state and the store unchanged and is reported. + Q_INVOKABLE bool setOptionEnabled(const QString& id, bool enabled); + + /// Removes every key this store owns and returns the typed state to its + /// defaults. A failed write is reported and leaves the previous values in + /// place; the caller confirms before this runs. + Q_INVOKABLE bool resetToFactory(); + +signals: + void optionsChanged(); + void noticeChanged(); + +private: + struct Option + { + QString id; + QString label; + QString description; + QString settingsKey; + bool defaultValue = true; + bool value = true; + }; + + QSettings& store(); + void load(); + Option* findOption(const QString& id); + bool writeOptionValue(const Option& option, bool enabled); + void setOperationNotice(QString text); + + std::unique_ptr m_ownedStore; + QSettings* m_store = nullptr; + std::vector