diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b102a8..071fe51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- Add structured JSONHTML layouts with finite string, color, declared raster, inline + and atomic MathML previews; preserve exact source, complete export/print and shared + CLI policy. Native, browser and WASI evidence is recorded with remaining assistive + and host qualification boundaries. + - Add E2's shallow JSON parsing, checked object construction, decimal-text and pre-parsed Pointer entry points; exact-key membership and three-state presence composition; and direct validation from an immutable compiled schema plan. diff --git a/Documentation/Architecture/API.md b/Documentation/Architecture/API.md index e23965b..3a6c829 100644 --- a/Documentation/Architecture/API.md +++ b/Documentation/Architecture/API.md @@ -14,7 +14,7 @@ in the [README](../../README.md) are compiled and executed by the public consume | JSONStreaming | JSONEventSequence, JSONValueSequence, NDJSONValueSequence, JSONTopLevelArraySequence, JSONAsyncWriter | Exclusive pull state, shared codec, strict framing and awaited handoffs | | JSONPatch | JSONPatch, JSONPatchOperation, JSONPatchResult, JSONMergePatch | Transaction staging, exact comparison and iterative editing | | swift-json | format, validate, pointer, patch, query, stream, schema, render | Bounded process/file adapters and private CLI engine/filter runtime; no public query library | -| JSONHTML | JSONHTMLRenderer, JSONHTMLRenderingOptions, JSONHTMLLimits, JSONHTMLRenderReport, JSONHTMLAnnotation, JSONExportDocument, JSONExportOptions, JSONExportReport | Exact HTML, native Apple transfer, immutable prepared JSON/text/Markdown/static HTML/opt-in styled RTF and cumulative budgets | +| JSONHTML | JSONHTMLRenderer, JSONHTMLRenderingOptions, JSONHTMLLimits, JSONHTMLRenderReport, JSONHTMLAnnotation, JSONExportDocument, JSONExportOptions, JSONExportReport | Structured forms/cards and bounded native previews, exact HTML, native Apple transfer, immutable prepared JSON/text/Markdown/static HTML/opt-in styled RTF and cumulative budgets | JSON imports no siblings. JSONSchema depends on JSON and package-private JSONRegexRuntime. Streaming, Patch, HTML and Bridges depend on JSON. There are seven public products: six libraries and the swift-json executable, @@ -213,6 +213,14 @@ Caller annotations use exact typed paths, explicit missing-parent attachment and coherent value byte spans without schema execution or source-byte archival. See [diagnostic rendering](Release1.3/DiagnosticAnnotations.md). +## Structured HTML and previews + +Layout and presentation are orthogonal. Direct defaults are structured interactive +inspection; prepared/transfer defaults are structured static complete values. Typed +preview hints, the closed inline/MathML dialect and finite eligibility counters are +defined in [the additive contract](Release1.3/StructuredPreviews/APIAndPolicy.md). +Core exact values, native writer defaults and all 49 metrics retain their meanings. + ## Provider-specific schema fragments (1.4 development) `OpenAIJSONSchemaBridge(profile:limits:)` in JSONSchema provides `check`/`checkWithReport`, diff --git a/Documentation/Architecture/ExtractionManifest.json b/Documentation/Architecture/ExtractionManifest.json index 0668a65..d6a58b7 100644 --- a/Documentation/Architecture/ExtractionManifest.json +++ b/Documentation/Architecture/ExtractionManifest.json @@ -189,6 +189,56 @@ "validation": "Tests/JSONSchemaTests/EvaluationOutputTests.swift", "evidence": "Scripts/ci.py: current native, safety and documentation receipts" }, + { + "repository": "ThorFuchs/Swift-JSON approved structured design", + "commit": "b078e86df3403d7f890107303066fdbc24414f70", + "original_path": "approved HTML/CSS/JS and enrichment gold fixtures in consolidated revision 0.2", + "destination": "Sources/JSONHTML/Assets and Tests/JSONHTMLTests/Fixtures/Enrichment", + "disposition": "Visual guidance and original native adaptation; enrichment test inputs copied byte-for-byte. No reference demo payload/runtime code is executed as a parser or shipped UI authority.", + "attribution": "Original project material, Apache-2.0; existing project/upstream notices retained.", + "fixture_files": [ + { + "path": "Tests/JSONHTMLTests/Fixtures/Enrichment/README.md", + "sha256": "a471b445fa1f1492d01537c6774fd26c62389bf0630f456f00f2261be58c3aaa" + }, + { + "path": "Tests/JSONHTMLTests/Fixtures/Enrichment/base64-cases.json", + "sha256": "1e5a1db4a549db39042a94fd136b07143852d5206cf4451ef2693823f228f671" + }, + { + "path": "Tests/JSONHTMLTests/Fixtures/Enrichment/colors.json", + "sha256": "8b5e50d5a471f3ea116cf5cc30bc60c19f50e7a1c69e0067cae2b8696b7993e6" + }, + { + "path": "Tests/JSONHTMLTests/Fixtures/Enrichment/inline-cases.json", + "sha256": "dbb3a2c22c00cc9deb09266297364b92d3cfeaffa5e06735c6464e071ee65e27" + }, + { + "path": "Tests/JSONHTMLTests/Fixtures/Enrichment/math-cases.json", + "sha256": "7a69b554993ad67d6ed1886488ef3a90cdf570ff3fc0081c548632c9ea900e46" + }, + { + "path": "Tests/JSONHTMLTests/Fixtures/Enrichment/media.json", + "sha256": "eb6a8ae4188114e55b9ed29e6eb1e44e219c2d50cda9717f83b0733a44a1a331" + }, + { + "path": "Tests/JSONHTMLTests/Fixtures/Enrichment/oversized-header.png", + "sha256": "c0c9273e7eb56ec7db3669e2f894a31811a98bea731842465a5967d0ee5e3f62" + }, + { + "path": "Tests/JSONHTMLTests/Fixtures/Enrichment/sample.jpg", + "sha256": "cbfd30bbdf155b2fd1aa5e0253e2d47cc21c60bd508e4e3fb9d93be143b624f4" + }, + { + "path": "Tests/JSONHTMLTests/Fixtures/Enrichment/sample.png", + "sha256": "60d5457a4640d49877e80d18e52005f10ed9ae807688c97f6f1458ea4975147b" + }, + { + "path": "Tests/JSONHTMLTests/Fixtures/Enrichment/strings.json", + "sha256": "6a4d6a8d46f6b48cd166eea7a8fe7ec5e6e2b463907fa3da12a90d294ffb0486" + } + ] + }, { "repository": "Swift-JSON original implementation", "scope": "JSONSchema portable provider profiles/checks, exact Responses fragments and CLI C5 adapters", @@ -207,11 +257,19 @@ "Schema graph, explicit evaluator, exact sets/assertions and scalar format recognizers are original implementations from Draft 2020-12, RFC3986/3987/3339/3492/5321/5891/5892/5893/6531/6570/6901 and fixed Unicode13 data.", "Private FIPS 180-4 SHA-256 is an original Swift implementation with checked cumulative production accounting, bounded scratch and inner cancellation. The separate trusted fixture tool mirrors its primitive core byte-for-byte; public_check verifies parity. No CryptoKit fallback or runtime dependency is introduced.", "JSONHTML formatted rendering, original fixed CSS and shared strict-writer token classification are original Apache-2.0 code. No third-party renderer, browser numeric authority or runtime dependency is adapted.", - "JSONHTML typed annotations, iterative exact-path binding, static/interactive attachment and coherent JSONDocument span presentation are original Apache-2.0 code. No schema engine, third-party renderer, source archive or new dependency is adapted.", "CLI foundation, argv parser, POSIX adapters, prepaid bounded diagnostics and shared-meter package adapters are original Apache-2.0 Swift code. No legacy filter engine or jq source is copied. See Release1.4/Foundation.md for native test-harness reuse provenance.", - "Tests/Fixtures/CLI/NewCases.json is the byte-identical 111-case authored CLI briefing fixture, SHA-256 6f863f3e98d2dea12e59018e7cf3a074ae0ebee5f1ab950a651df72e5eb18a4c. Fixture expected JSON texts use native exact comparison, not a floating-point oracle. The query compiler/VM and lifetime ledger are original Apache-2.0 implementations; no legacy eager evaluator or jq implementation is copied." + "Tests/Fixtures/CLI/NewCases.json is the byte-identical 111-case authored CLI briefing fixture, SHA-256 6f863f3e98d2dea12e59018e7cf3a074ae0ebee5f1ab950a651df72e5eb18a4c. Fixture expected JSON texts use native exact comparison, not a floating-point oracle. The query compiler/VM and lifetime ledger are original Apache-2.0 implementations; no legacy eager evaluator or jq implementation is copied.", + "JSONHTML typed annotations, iterative exact-path binding, static/interactive attachment and coherent JSONDocument span presentation are original Apache-2.0 code. No schema engine, third-party renderer, source archive or new dependency is adapted.", + "Structured forms/cards, finite prefix/preview staging, closed inline scanner, atomic mini-math MathML emitter, canonical PNG/JPEG header admission and line/media-bounded PDF pagination are original Apache-2.0 code. The approved structured concept informed the fixed CSS/JS; no demo Site content or external parser/renderer/codec engine is distributed." ], "policy": "Record actual adaptations and retain applicable upstream copyrights and licenses. Reference inputs and comparison experiments are not distributed runtime dependencies.", + "original_notes": [ + "1.4 C3 decimal magnitude, rational/quantization kernel, pull range and type-directed overloads are original Apache-2.0 code. The 738-case deterministic oracle uses Python integer/Fraction arithmetic; no jq/Rhoe arithmetic implementation was copied.", + "1.4 inherited CLI qualification fixtures are copied byte-for-byte: RhoePlatform/RhoeJSON pin 51d37acc137422065b07ebeb7e4ecdb2d02b99ee, 119 golden and 49 subset rows. Apache-2.0 license retained in Licenses/RhoeJSON-CLI-Fixtures-Apache-2.0.txt. PlanningDispositions labels are provisional, not evaluated successes. New explicit-stack CLI code is original; old eager JSONValue evaluator and arithmetic were not ported.", + "1.4 C4 immutable IR/function/pattern metadata, invocation closures/thunks, continuations, scalar string and format helpers, root-provenance paths and transactional mutation are original Apache-2.0 code. No eager upstream evaluator, Foundation regex or approximate arithmetic was copied.", + "1.4 C5 stream/offline-schema adapters, checked sink handoff, command help and consumers are original Apache-2.0 code. Five NewInputs fixtures are copied byte-for-byte from the maintainer CLI briefing pack 43f8819c57e3d93bc6f7cf561d2d111442e089f2973cb7c62e16e3bd3fd73d0d; these authored data fixtures introduce no runtime dependency. Missing provider helper prerequisites remain blocked rather than adapted or silently rewritten.", + "1.4 checked schema resource lookup and its missing/quota/cancellation/install regressions are original Apache-2.0 code. Supported app/framework/command layouts retain all nine official resource pins; no unbounded directory scan, fallback engine or external dependency is introduced." + ], "jsonhtml_export_original_contributions": { "license": "Apache-2.0", "adapted_third_party_code": false, @@ -220,12 +278,5 @@ "framework_boundary": "Conditional CoreTransferable/UniformTypeIdentifiers plus a narrow explicit main-actor Apple PDF boundary using WebKit, CoreText, CoreGraphics and AppKit/UIKit paragraph styles. Core JSON imports none of these; no new root dependency.", "rtf": "Original bounded strict-token emitter; AppKit/UIKit native decoding only in tests and qualification consumers, not generation.", "pdf": "Original invocation-owned lifecycle, finite adapter accounting and visible-range pagination using Apple SDK APIs. PDFKit is independent qualification only; no third-party renderer adapted." - }, - "original_notes": [ - "1.4 C3 decimal magnitude, rational/quantization kernel, pull range and type-directed overloads are original Apache-2.0 code. The 738-case deterministic oracle uses Python integer/Fraction arithmetic; no jq/Rhoe arithmetic implementation was copied.", - "1.4 inherited CLI qualification fixtures are copied byte-for-byte: RhoePlatform/RhoeJSON pin 51d37acc137422065b07ebeb7e4ecdb2d02b99ee, 119 golden and 49 subset rows. Apache-2.0 license retained in Licenses/RhoeJSON-CLI-Fixtures-Apache-2.0.txt. PlanningDispositions labels are provisional, not evaluated successes. New explicit-stack CLI code is original; old eager JSONValue evaluator and arithmetic were not ported.", - "1.4 C4 immutable IR/function/pattern metadata, invocation closures/thunks, continuations, scalar string and format helpers, root-provenance paths and transactional mutation are original Apache-2.0 code. No eager upstream evaluator, Foundation regex or approximate arithmetic was copied.", - "1.4 C5 stream/offline-schema adapters, checked sink handoff, command help and consumers are original Apache-2.0 code. Five NewInputs fixtures are copied byte-for-byte from the maintainer CLI briefing pack 43f8819c57e3d93bc6f7cf561d2d111442e089f2973cb7c62e16e3bd3fd73d0d; these authored data fixtures introduce no runtime dependency. Missing provider helper prerequisites remain blocked rather than adapted or silently rewritten.", - "1.4 checked schema resource lookup and its missing/quota/cancellation/install regressions are original Apache-2.0 code. Supported app/framework/command layouts retain all nine official resource pins; no unbounded directory scan, fallback engine or external dependency is introduced." - ] + } } diff --git a/Documentation/Architecture/Release1.3/Progress.json b/Documentation/Architecture/Release1.3/Progress.json index 7c0fed7..2781770 100644 --- a/Documentation/Architecture/Release1.3/Progress.json +++ b/Documentation/Architecture/Release1.3/Progress.json @@ -306,5 +306,116 @@ "Representative calibration only; no maximum-workload/RSS/opaque SDK instruction guarantees", "Formatted audible speech not part of the new interactive annotation check" ] + }, + "structured_preview_increment": { + "briefing_revision": "0.2", + "release_assignment": null, + "branch": "codex/jsonhtml-structured-previews", + "status": "L5/P5 scoped qualification complete; dependent draft PR remains unmerged", + "native_library_head": "76e4b262c4247ca3784b62b97bc21321e43d4044", + "source_preserving_cli_base": "aded3c9930f580bd4fb902bba002ab75ed199a7b", + "native_functions": 372, + "native_parameter_cases": 325, + "core_metric_count": 49, + "browser_cases": 76, + "supplemental_browser_contexts": 48, + "ipad_simulator_cases": 73, + "preview_policy_ceilings": 22, + "requirements": 110, + "scenarios": 88, + "calibration_configurations": 96, + "remaining": [], + "qualification_contract": "StructuredPreviews/Qualification.md", + "history_preserved": true, + "publication_performed": false, + "wasi": { + "source_head": "7fe0226", + "status": "passed", + "probes": 71, + "functions_per_configuration": 277, + "parameters_per_configuration": 288, + "public_consumer_functions": 14, + "cap_metrics": 49, + "mcp_server_claim": false + }, + "qualification_follow_up": { + "source_head": "d89be60b66e753bb5f4be1b414710ab8c6155376", + "hosted_native_run": 38657093294, + "hosted_linux_run": 38657093290, + "hosted_checks_passed": 7, + "linux_architectures": [ + "x86_64", + "arm64" + ], + "linux_functions_per_configuration": 356, + "linux_parameters_per_configuration": 325, + "physical_iphone_cases": 73, + "physical_iphone_artifacts": 8, + "native_safari_contexts": 16, + "native_safari_scope": "Accessibility/control smoke; selected five responsive widths, console/network; separate automated layout/zoom scope retained", + "voiceover": "Maintainer manually confirmed field/type/index, disclosure state and inline math; original off preference restored", + "additional_public_swift_testing_functions": 6, + "additional_search_cases": 9, + "history_preserved": true, + "library_changes": false + }, + "qualification_touch_follow_up": { + "tested_source_head": "e0d5f0c26519c3eaa5faaa62f4104cc10ad5c419", + "hosted_native_run": 38673549572, + "hosted_linux_run": 38673549500, + "hosted_checks_passed": 7, + "requirements_passed": 110, + "scenarios_passed": 88, + "physical_iphone_consumer_cases": 73, + "physical_iphone_consumer_artifacts": 8, + "physical_touch": "Maintainer confirmed requested tab/disclosure/search, media, rotation and script-off access after fixture findings; bounded native observation supplements the manual result", + "fixture_findings": [ + "Original 4x4 admission samples decode but are too small for recognizable visual QA", + "Inline expressions crossing the finite prefix or destination ceiling remain literal; compact 415-scalar image qualified" + ], + "library_changes": false, + "limits_widened": false, + "history_preserved": true, + "merge_tag_publication_performed": false + }, + "advanced_cli_base_reconciliation": { + "feature_parent": "1fd32d19b4e16891073cb9fdba7ecdcc7660bf88", + "cli_base": "c3760730dc333a84810bba53cde4da339ada90af", + "contract": "StructuredPreviews/Reconciliation.md", + "source_reconciled": true, + "html_implementation_tests_consumers_unchanged": true, + "provider_implementation_tests_fixtures_preserved": true, + "qualification_policy": "Fresh combined-source CI receipts required; original qualified-source and physical-observation histories retained. Exact outcomes are materialized separately.", + "expected_apple_functions": 393, + "expected_linux_functions": 377, + "root_parameter_cases": 325, + "expected_wasi_functions": 290, + "wasi_parameter_cases": 288, + "merge_tag_publication_performed": false + }, + "approved_bridges_cli_reconciliation": { + "feature_parent": "e1b82a583f66afffeec992a95a6e08ad36a1c3f0", + "cli_base": "aab19ce3414b80e5fef69ffd1d895bd0a2c2cadb", + "maintainer_approved_bridges": true, + "public_library_count": 6, + "public_executable_count": 1, + "expected_apple_functions": 419, + "expected_linux_functions": 403, + "root_parameter_cases": 325, + "expected_apple_shared_functions": 15, + "expected_linux_shared_functions": 14, + "expected_wasi_functions": 316, + "wasi_parameter_cases": 288, + "qualification_policy": "Fresh combined-source receipts required; parent receipts immutable; unchanged HTML physical/browser evidence retains its scoped source identity.", + "contract": "StructuredPreviews/Reconciliation.md", + "merge_tag_publication_performed": false + }, + "final_merge_review": { + "finding": "Existing main export lane and event I/O diagnostics were absent from the reconciled harness", + "resolution": "Restore main export commands/CLI choice and diagnostic receipt fields; retain combined [1,39] focused discovery guard", + "library_cli_asset_fixture_changes": false, + "qualification_policy": "Fresh hosted required checks and actual local export lane; unchanged implementation/WASI receipts retained through equivalence", + "merge_authorization": "Maintainer requested ready and merge after checks/reviews pass; tags/publication separate" + } } } diff --git a/Documentation/Architecture/Release1.3/StructuredPreviews/APIAndPolicy.md b/Documentation/Architecture/Release1.3/StructuredPreviews/APIAndPolicy.md new file mode 100644 index 0000000..395e7be --- /dev/null +++ b/Documentation/Architecture/Release1.3/StructuredPreviews/APIAndPolicy.md @@ -0,0 +1,143 @@ +# Structured layout and native preview contract + +This additive work implements the maintainer's consolidated briefing revision 0.2. +The revision is a design input, not a package release number. Release placement is +unassigned. Earlier Release1.3 and Release1.4 records retain their historical gates. + +The approved current baseline exposes six libraries (JSON, JSONSchema, JSONStreaming, +JSONPatch, JSONHTML and opt-in JSONBridges) plus the `swift-json` executable. This +supersedes the earlier five-library forward inventory; see [reconciliation](Reconciliation.md). +JSONBridges depends on JSON only and is never re-exported by core. External dependencies remain +empty. JSONHTML imports JSON; it does not require schema, an image codec, a Markdown +engine, a TeX engine or a browser at generation time. Original code is Apache-2.0. + +| Entry point | Layout | Presentation | Values | Print/PDF | +| --- | --- | --- | --- | --- | +| Direct renderer | structured | interactive | inspection | complete | +| Prepared export / direct transfer | structured | formatted (static) | complete | complete | +| CLI HTML | structured | static | inspection | complete | +| Explicit syntax | syntax | caller choice | exact full JSON | exact full JSON | + +Layout and presentation are independent. The existing six- and nine-argument +`JSONHTMLRenderingOptions.init` function shapes remain available. New callers can +use the required `layout:` overload or mutate the copied options. Default HTML bytes, +DOM size, appearance and print geometry intentionally change. Select `.syntax` and +`.formatted` to retain the former code-only rendering contract. Core JSON writer +formatting defaults and non-HTML export bytes are unchanged. + +`JSONHTMLLayout`, `JSONHTMLValueProfile`, `JSONHTMLInlineFormatting`, +`JSONHTMLLinkActivation`, `JSONHTMLPreviewPolicy` and `JSONHTMLPreviewHint` are checked +Sendable values. Hint paths use `[JSONLocationComponent]`; key `"0"` and index `0` +are distinct. Explicit declarations precede caller-translated effective annotations, +complete color/data-URI recognition, automatic inline formatting and literal output. +Duplicate declarations in a tier are configuration errors. Missing/wrong-kind or +unavailable declarations generate bounded issues and retain source. Literal hints +suppress inference. Syntax does not run the preview pipeline. Schema validity is +independent of preview admission. + +Nonempty ordered objects use `dl/dt/dd` forms. Complex or mixed arrays use ordered +indexed cards. Scalar flow eligibility is inclusive at 10 items and 60 display-token +Unicode scalars, including quotation/control escapes before HTML entity encoding. +The original full token determines eligibility. Other arrays use indexed lists. +Every root, empty container, scalar and empty key has an explicit kind/value cue. +Static containers are ordinary expanded markup. Interactive containers have native +summaries; original-value disclosures and both source views work without scripting. +Visual depth stops adding rails/padding at level 3; JSON structure is never truncated. + +Inspection prefixes independently cap decoded scalars at 512, UTF-8 prefix bytes at +2048 and escaped display scalars at 512. Prefixes never split a scalar or a reversible +control escape. Full original strings remain in native Original value disclosures +and complete JSON source. Complete values/print expand original strings. Keys, +numbers and JSON nodes are never shortened. C0/C1, the documented format/bidi ranges +in `JSONHTMLDisplay.protected`, quotes and backslashes have reversible structured +display. Full JSON/source/copy/download retain native writer bytes and numeric +lexemes; source text is bidi-isolated. Display escaping does not normalize scalars. + +The closed inline dialect supports star bold/italic, plain/emphasized link labels, +declared embedded images and inline math. Backtick runs, fences and block-looking +lines remain literal. Parsing occurs only in the admitted prefix. Links remain inert +by default; explicit activation permits validated credential-free absolute HTTP/HTTPS +user navigation. Remote images, SVG input, raw markup, document Markdown, arbitrary +CSS/MathML, generated-HTML replacements and external parser dependencies are excluded. + +Mini math supports the fixed Latin/Greek/operator mapping, groups, decimal tokens, +exact fraction/radical arities and at most one subscript/superscript per atom. +Unbraced multi-character arguments, unknown commands, malformed, ambiguous or cutoff +spans remain wholly literal. Single-child groups are unwrapped as the fixture's +permitted canonicalization. Math is presentation only; fractions are not evaluated. +Output uses the fixed MathML Core allowlist. No partial parse is committed. + +Color strings use complete #RGB/#RGBA/#RRGGBB/#RRGGBBAA and comma rgb/rgba grammar. +Numeric hinted channels require explicit integer8 or normalized units and rgb/rgba +order. Exact native range checks precede appearance conversion; alpha slightly above +one is rejected even when Double would round it to one. Controlled SVG swatches +include checkerboard alpha backgrounds and separate channel text. Only geometry and +validated channels enter their generated attributes. + +Declared PNG/JPEG uses canonical standard Base64 with exact padding and zero pad +bits. Native admission checks signature, bounded headers/structure/dimensions/pixels, +IHDR CRC and framing. It is not a full raster codec or browser RSS guarantee. Image +count and aggregate decoded bytes are counted once per unique staged preview, even +when screen/print representations emit it again. `img-src data:` is authorized only +when admitted media is emitted; external resources remain denied. + +`PreviewPolicy.json` freezes all 22 proposed eligibility ceilings. These supplement +unchanged core/HTML/export/PDF ceilings. All 49 core metric meanings and presets are +preserved. Decoded binary media contributes to logical retained bytes and the separate +`decodedImageBytes` statistic, not the emitted UTF-8 metric. No nested facade resets +work. Admission, staging, all duplicate bodies, assets, escaping, Base64 and final +copies share one invocation meter. Local syntax/eligibility rejection refunds only +live scratch; real resource/configuration/cancellation failures remain terminal. + +Prepared HTML retains immutable layout/value/print and font/media readiness policy. +Explicit asynchronous Apple PDF consumes that artifact, awaits fonts/image decoding, +forces its print representation, and never regenerates JSONHTML. Syntax pagination +retains CoreText visible ranges. Structured pagination collects native line/media +bounds (including controlled swatches) one DOM step at a time, chooses integer whitespace cuts and composites native +regions. Each capture checks and hides off-region indexed rows/cards one DOM +node at a time; composition clips to the admitted region. This avoids SDK capture +retaining neighboring selectable text. All scans and visibility calls charge the +same PDF job meter and check cancellation. An unbreakable fragment larger than a page fails explicitly; no syntax +fallback is selected. SDK decoding/layout/capture work and allocations are outside +owned instruction/RSS claims. Actual readiness/output/font evidence is reported +separately from the native generation policy. + +The CLI adds HTML-only `--layout structured|syntax`, `--string-preview N` versus +`--full-values`, `--inline-formatting off|declared|automatic`, and `--value-previews + off|standard`. N is checked/nonnegative, zero is meaningful, and its UTF-8 allowance +is min(core stringBytes, checked 4*N). Invalid/non-HTML/conflicting choices remain +usage exit 2. Atomic output and the `--html PATH` conflict rules are unchanged. + +Browser search retains the existing 1024-byte query ceiling and 100 listed/highlighted +node limit. It reports total matching nodes separately from the listed subset and +wraps only within that disclosed set. It excludes Original value and print/source +duplicates from structured search. Syntax search is a separate exact-source scope. +Optional browser Download HTML is omitted: callers save immutable renderer/export +bytes; no mutated DOM serialization or recursive HTML payload is shipped. + +Preview logical weights are versioned in this revision: 64 bytes per staged node or +inline token, 32 per inline delimiter frame, four per scalar, 64 per math node/frame +plus eight per child slot and admitted token UTF-8, actual encoded/decoded/prefix +bytes, and 256 for active structured emission scratch. PDF fragment bounds reserve +32 bytes each and its fixed native cursor 128. Borrowed immutable fixed asset/mapping +tables are shared; no invocation stores a global mutable cache. These are logical +accounting weights, not Swift allocator measurements or process RSS promises. + +Math's token ceiling counts consumed identifiers/numbers/commands/operators/braces/ +script markers, rather than interpreter decisions. Interpreter loops remain covered +by cumulative work/cancellation. Number/command staging reserves source UTF-8 plus +128 fixed scratch bytes; MathML emission reserves 32 and swatch emission 64. The +structured source-download anchor reuses exact native UTF-8 and works without JS; +its Base64 duplication is metered. `copyJSONBytes` describes the one raw payload, +while emitted metadata/output counts each copy. Legacy static syntax retains its +original code-only counters. + +The approved responsive transitions are 1200px (outer spacing/title), 920px +(full-width body and compact outline) and 580px (stacked key/value fields and +full-row search). At visual depth bucket 3, nested record borders and padding +stop accumulating. The complete 120-container alternating fixture is checked +at 320px; static source links share the themed accessible link color. + +Complete values expand the full original string. Derived inline previews retain their +finite prefix and destination ceilings in both value profiles; complete output does +not promise that every expression in a long string is interpreted as rich content. diff --git a/Documentation/Architecture/Release1.3/StructuredPreviews/Plan.md b/Documentation/Architecture/Release1.3/StructuredPreviews/Plan.md new file mode 100644 index 0000000..7ea755d --- /dev/null +++ b/Documentation/Architecture/Release1.3/StructuredPreviews/Plan.md @@ -0,0 +1,23 @@ +# Consolidated structured-preview increment + +This feature is a dependent branch, not a release. Its initial committed CLI base +is `9a773f34fd35aa686ddf9d28bb0546621f3e9000`; the reused export/annotation/PDF input is +`a0a73b0`. Commit `9beb9f4` records their prerequisite reconciliation. The active +feature branch is `codex/jsonhtml-structured-previews`. Main and sibling working +copies remain independent. Current committed CLI fixes are reconciled separately. + +L0/P0 freezes orthogonal checked Sendable options, source-compatible initializer +shapes, six root products, empty external dependencies, finite policies and source +identity. L1/P1–P4 implements native iterative forms/cards, exact typed paths, +bounded prefixes/colors/media/inline/math and operation-error taxonomy. L2/L3 +qualifies fixed assets, responsive/accessibility behavior, progressive views, bounded +search and exact source downloads. L4 composes immutable static transfer/export, +complete print, explicit guarded PDF and HTML-only CLI. L5/P5 requires native, +safety, documentation, public consumers, actual portable/mobile/browser/assistive +observations, calibration, reproducible archive and requirement/scenario evidence. + +The new acceptance ledger contains 110 requirements and 88 scenarios. Historical +JH/TX/CLI/W/G/RC evidence is retained. Changed default-layout/print/copy/DOM behavior +gets new evidence; unchanged core policies and independent input pins are reused. +Missing hosts/equipment remain blocked or not_run. A draft PR is the review boundary; +no merge, tag, publication, demo Site edit or outreach is part of this work. diff --git a/Documentation/Architecture/Release1.3/StructuredPreviews/PreviewPolicy.json b/Documentation/Architecture/Release1.3/StructuredPreviews/PreviewPolicy.json new file mode 100644 index 0000000..5d1baf9 --- /dev/null +++ b/Documentation/Architecture/Release1.3/StructuredPreviews/PreviewPolicy.json @@ -0,0 +1,93 @@ +{ + "version": 1, + "status": "frozen_native_measured_candidate; complete_P5_qualification_pending", + "core_limits_unchanged": true, + "defaults": { + "rendererStructuredProfile": "inspection", + "cliHTMLProfile": "inspection", + "transferHTMLProfile": "complete", + "preparedHTMLProfile": "complete", + "printProfile": "complete", + "inlineFormatting": "automatic", + "colorPreviews": true, + "declaredEmbeddedImages": true, + "linkActivation": "disabled" + }, + "strings": { + "maxSourceScalars": 512, + "maxPrefixUTF8Bytes": 2048, + "maxEscapedDisplayScalars": 512 + }, + "inline": { + "maxNesting": 8, + "maxTokens": 1024, + "maxDestinationUTF8Bytes": 2048, + "maxAltScalars": 128 + }, + "math": { + "maxSourceScalars": 256, + "maxSourceUTF8Bytes": 1024, + "maxSyntaxDepth": 16, + "maxStagedNodes": 256, + "maxTokens": 512, + "delimiters": [ + "singleDollar", + "backslashParentheses" + ], + "greekCommands": { + "alpha": "\u03b1", + "beta": "\u03b2", + "gamma": "\u03b3", + "delta": "\u03b4", + "theta": "\u03b8", + "lambda": "\u03bb", + "mu": "\u03bc", + "pi": "\u03c0", + "sigma": "\u03c3", + "phi": "\u03c6", + "omega": "\u03c9", + "Delta": "\u0394", + "Omega": "\u03a9" + }, + "operatorCommands": { + "times": "\u00d7", + "cdot": "\u22c5", + "pm": "\u00b1", + "le": "\u2264", + "ge": "\u2265", + "ne": "\u2260" + } + }, + "media": { + "encodedCandidateBytes": 3145728, + "decodedBytesPerImage": 2097152, + "aggregateDecodedBytes": 8388608, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 4194304, + "headerScanBytes": 262144, + "maxImagePreviews": 64, + "allowedMediaTypes": [ + "image/png", + "image/jpeg" + ], + "remoteLoading": false + }, + "hints": { + "maxRecords": 1024 + }, + "color": { + "maxCandidateUTF8Bytes": 256, + "hexOrder": "CSS_RGB_RGBA", + "namedColors": false + }, + "accounting": "Eligibility policies supplement the one cumulative core/HTML meter; real global exhaustion is terminal. All finite values require checked representability and calibration.", + "native_calibration": { + "configurations": 96, + "repeated_renders": 672, + "behavior_checks": 674, + "finite_boundary_parameters": 22, + "core_metric_meanings_unchanged": 49, + "rss_guarantee": false + } +} diff --git a/Documentation/Architecture/Release1.3/StructuredPreviews/Qualification.md b/Documentation/Architecture/Release1.3/StructuredPreviews/Qualification.md new file mode 100644 index 0000000..9f3190d --- /dev/null +++ b/Documentation/Architecture/Release1.3/StructuredPreviews/Qualification.md @@ -0,0 +1,134 @@ +# Structured previews: qualification boundary + +This is the consolidated revision 0.2 increment, not a package release. The dependent +branch preserves the completed W/G/RC and 1.3/CLI histories. The executable and five +libraries remain the only root products; external dependencies remain empty. + +The implemented L0–L4 / P0–P4 behavior includes the independent structured/syntax and +interactive/static choices, exact native source, finite prefix/color/media/inline/math +policies, prepared complete exports/print, guarded PDF and shared HTML-only CLI flags. +See [API/policy](APIAndPolicy.md) and [finite policy](PreviewPolicy.json). + +Native Swift 6.4, language 6, macOS 27.0, Xcode 27.2 and SDK 27.2 qualification at library +commit `76e4b262c4247ca3784b62b97bc21321e43d4044` passed 25 native commands, 372 debug/release +behavior functions and 325 parameter cases. All 49 cap probes and pinned independent +suites retain exact cardinality: JSON 318 observed/287 evaluated/31 policy and schema +2337/2300/37. Exclusions are not evaluated successes. ASAN 372/325, TSan 11 and strict +memory safety passed. Five DocC catalogues, declaration comments and compiled README +examples passed. + +The fixed assets passed 76 browser cases in three pinned engines. Separate stricter +host/no-script observations cover 48 contexts/576 layout checks, 25 approved/deep +viewport cases, 16 measured text contrasts and forced colors. Complete and explicit +inspection printing and corrupt-raster no-script source access have separate results. +Playwright WebKit is supplementary and cannot certify actual Safari or VoiceOver. + +Native export/PDF passed 65 public consumer cases/eight artifacts, 150 exact-once row +markers in A4/Letter plus tail, media/math/full strings and failed-decoder semantics. +The universal public Apple consumer built on both mobile SDKs; iPadOS 27.0 simulator +execution passed 73 cases/eight artifacts. The subsequent unlocked physical iPhone +run on iOS 27.2 also passed 73 cases/eight artifacts; its actual files were copied +back. Headless execution does not establish physical touch or share-sheet behavior. + +Native calibration uses eight bounded datasets, three finite profiles, 96 option +combinations, 672 repeated renders and 674 behavior checks. Browser observations use 18 +engine/dataset pairs, 90 loads and 75 search/disclosure samples each. Shared-host elapsed +time is not an idle-host comparison or throughput guarantee. Logical retained bytes, +process RSS, caller work and owned quotas remain distinct. The inherited 1.075s versus +0.96849s stream median (+11.0%) retains its existing reviewed disposition and 10% trigger. + +The native touch/portrait-landscape and media follow-up is recorded below; earlier +open-gate observations remain in the private evidence history. +The supplied return materializes 110 requirement rows, 88 scenario rows, 48 requested +browser contexts, actual source archive, per-file manifests, command receipts and +failed-probe history. It does not turn related test counts into unexecuted scenario +passes. The matching Swift 6.4 WASI lane at `7fe0226` passed all 71 probes: debug/release +277 root functions and 288 parameter cases, 14 public consumer functions, all 49 +cap probes, 20 rooted vectors, pinned independent suites, strict memory safety and +missing-resource/runtime observations. Its original receipts remain source-bound. + +A source-preserving CLI ancestry reconciliation and WASI-only test/harness changes +leave all 194 library/manifest files byte-identical to the qualified native source. +The added public annotation consumer executed separately. Later documentation-only +changes reuse unchanged-source evidence with explicit equivalence, preserving original +receipt commits. The source archive's actual digest is recorded outside its own contents +to avoid a circular claim. + +No merge, tag, release, Site/social edit or outreach is authorized by this increment. +The focused draft PR is dependent on the current CLI integration stack. Qualification +evidence does not itself authorize merging or publication. + +## Qualification follow-up + +PR #30 at `d89be60b66e753bb5f4be1b414710ab8c6155376` passed all seven hosted native, +safety, DocC and Linux checks. Native run `38657093294` tested GitHub's merge commit +`adc9d424fd56253ba8d7885ad73418f33d1b5ccd`. Linux run `38657093290` executed Swift 6.4 +on Ubuntu 24.04/glibc 2.39 on both x86_64 and arm64: debug/release 356 functions and +325 parameter cases per architecture, ASAN 356/325, TSan 11 and strict memory safety. +All seven downloaded lane receipts preserve their original identities; their 991 +input file hashes match the reviewed source. Linux's source-archive invocation has +no Git directory, so its manifest, run and architecture identify the input. + +Actual Safari 27.0 (`22625.1.29.11.24`) on macOS 27.0 exercised 16 file/served +presentation contexts, including JavaScript disabled and stricter CSP. Native +accessibility trees confirmed controls and script-free fallback; selected 320, 390, +768, 1200 and 1440 point viewports were inspected. Console and local network +observations supplement the pinned automated engines. This is a native smoke and +selected-viewport scope; the three automated engines retain their separately +recorded layout/zoom/motion counts. WebKit is never relabelled as Safari. + +The maintainer manually checked VoiceOver on that artifact and reported that field, +type/index labels, disclosure state and the inline math expression worked as expected. +This is attributed human evidence, not an automated spoken-output assertion. The +JavaScript and VoiceOver preferences were restored to their observed original values. + +Six additional public-API Swift Testing probes passed against unchanged library +bytes. They replay the resolved default depth 128 and rejected 129, supplied workspace +annotations and project source mappings, exact repeated-search fixtures and both +pinned non-HTML numeric/scalar fixtures. The 500-field long-string card explicitly +exceeds the unchanged 14,400 point PDF ceiling; a finite 60-field adaptation produces +ten pages with every tail marker exactly once. Nine additional browser cases cover +100/1000/1100 repeated records, the 100-result/highlight bound, query byte overflow +and cancellation of stale searches. TextEdit reception of the pinned exports is +recorded separately from byte/content-type/selection assertions. No library feature, +quota, platform, dependency or product policy changed in this follow-up. + +## Physical touch and media closure — 2026-10-15 + +All seven hosted checks at `e0d5f0c26519c3eaa5faaa62f4104cc10ad5c419` passed: +Native run `38673549572` and Linux run `38673549500`. Downloaded command logs and +all 991 source-input hashes were verified against that checkout. Native debug/release +and ASAN remain 372 functions/325 parameter cases; each Linux architecture remains +356/325, with TSan 11 and the existing strict-memory/cap/conformance checks retained. + +The actual iPhone consumer rerun passed 73 cases/eight artifacts. The maintainer then +performed finger-touch tab/disclosure/search, portrait-landscape-portrait, media and +script-disabled source/disclosure checks in a private WKWebView host. After the two +fixture findings below were isolated, the maintainer confirmed the requested checks +worked as expected. This closes JL26/PV38 and their dependent QC25/QC56/QC84 rows: +110/110 requirement rows and 88/88 scenario rows are recorded passed within their +existing scoped evidence. Human observations remain attributed to the maintainer. + +The initial 4-by-4-pixel admission samples decoded on the phone but were unsuitable +for a recognizable visual check. Original 256-by-144 PNG/JPEG QA supplements made the +pictures visible. A long inline data-URL expression remained literal at the unchanged +512-scalar preview boundary; a complete 415-scalar expression rendered a recognizable +64-by-36 inline image. Complete values expand original text; derived inline previews +still obey their prefix and destination eligibility ceilings. No renderer, asset, +limit, platform, product or dependency was changed to obtain these outcomes. + +Private device records separately preserve trusted touch events, 430-point portrait +and 814-point landscape observations, native image decode dimensions, and disabled +content JavaScript with the enhanced toolbar absent. They are supplementary to the +maintainer's final observation, not an automated assertion of every requested gesture. +The compact inline decode was captured with scripting enabled; script-off standalone +media was independently captured on the preceding fixture. The final combined manual +check covers the remaining user-visible behavior. Device identifiers, signed apps and +local evidence paths are excluded from public source. + +The self-contained return preserves the previous immutable source/archive and receipts, +materializes current-head hosted evidence and the test-host/fixture source, and retains +both initial findings and failed harness assumptions. Pinned browser probes distinguish +inspection cutoff, destination ineligibility, complete original text and positive +compact inline media. These additional fixtures do not replace the independent pins +or existing browser matrix. Merge, tag and publication remain separate decisions. diff --git a/Documentation/Architecture/Release1.3/StructuredPreviews/Reconciliation.md b/Documentation/Architecture/Release1.3/StructuredPreviews/Reconciliation.md new file mode 100644 index 0000000..ac31c71 --- /dev/null +++ b/Documentation/Architecture/Release1.3/StructuredPreviews/Reconciliation.md @@ -0,0 +1,87 @@ +# Reconcile the advanced CLI base + +PR #30 retains the structured-preview and physical-qualification history at +`1fd32d19b4e16891073cb9fdba7ecdcc7660bf88` and incorporates the CLI/provider base +`c3760730dc333a84810bba53cde4da339ada90af`. This updates the feature branch only; +PR merging, tagging and publication remain separate maintainer actions. + +The four conflict resolutions retain both API sections and both complete extraction +records, including all enrichment/provider fixture pins. All original HTML source, +tests, browser/export consumers and existing preview contracts remain byte-identical +to the feature parent. Provider source, tests and frozen vectors remain byte-identical +to the advanced base. Automatically combined CLI help retains the provider commands +and all structured HTML switches. The package still has five libraries and the +sixth executable, private runtimes and no root external dependencies. + +The harness combines the inventories instead of selecting one parent's smaller set: + +| Scope | Exact inventory | +| --- | --- | +| Apple root | 393 functions: JSON 107, schema 65, stream 14, patch 11, regex 12, HTML 97, CLI 87 | +| Linux root | 377 functions; HTML has 81 portable functions, all other modules retain the Apple counts | +| Root parameters | 325 exact named case invocations on Apple/Linux | +| Shared-state TSan | 13 Apple / 12 Linux functions, including structured rendering and provider sharing; the Apple PDF concurrency case remains included | +| Focused export | 40 functions across two test modules: 39 HTML and one provider/schema export function | +| WASI root | 290 functions, including schema 65 and HTML 81; 288 named parameter cases | +| Public WASI consumer | 14 functions, including the separate six-case HTML mode | + +Swift Testing ABI events determine discovered module/function/parameter inventories. +The focused export probe exposed two separate runner summaries; its exact guard is +`[1, 39]`, not only the last console total. The shared-state guard also checks exact +module identities. Event-reader canaries, all 49 core metric probes, corpus observed/ +evaluated/policy counts, malformed replay, all six C5 vectors, the 14-case public +provider consumer and 37 provider process/fixture outcomes remain required. No +passing placeholder or blanket exit-zero/nonzero-cardinality check replaces them. + +The existing receipt identities stay bound to their original sources. Combined-source +native, safety, documentation, Linux and affected WASI outcomes need fresh receipts; +parent success alone does not qualify this integration. Actual commands, toolchains, +source/fixture hashes, discovery events, exits, preservation comparisons and hosted +run identities are materialized in the separate reconciliation handoff. The unchanged +HTML implementation retains its prior native browser/device/VoiceOver/calibration +scope, including attributed manual observations and the reviewed performance policy. + +## Reconcile the approved JSONBridges baseline + +The maintainer explicitly approved retaining current main's JSONBridges while +reconciling the advanced CLI base `aab19ce3414b80e5fef69ffd1d895bd0a2c2cadb`. +This supersedes the earlier forward five-library inventory, preserving its history. +The combined package has six libraries plus the swift-json executable, no external +root dependencies, JSON-only HTML/Bridges dependencies and private regex/filter/CLI +runtimes. Core does not re-export JSONBridges. The feature parent is +`e1b82a583f66afffeec992a95a6e08ad36a1c3f0`; its receipts remain immutable. + +The five new resolutions retain the structured HTML and E2 API/release descriptions, +all current README examples and both exact test inventories. JSONHTML test fixtures +remain packaged. Core/Bridges/schema facade additions and the provider implementation +are retained without source changes. No rendering behavior, preview ceiling, platform, +metric meaning or existing fixture is changed by this synchronization. + +The required combined inventory is 419 Apple / 403 Linux functions, with 325 named +parameter cases. Modules retain HTML 97/81 and CLI 87 while adding core 119, +schema 69 and Bridges 10. Shared-state TSan requires 15 Apple / 14 Linux functions, +including structured plans, providers, Apple PDF and both E2 sharing functions. +The WASI root requires 316 functions and 288 parameter cases, including Bridges 10, +core 119, schema 69 and HTML 81. All 49 caps, exact corpus policies, 19 event canaries, +public consumers, six DocC catalogues and eight E2 compiler/import checks remain +required. Discovery and execution receipts must confirm these inventories; expected +counts alone are not qualification. + +New source-bound native, safety, docs, Linux and affected WASI receipts are recorded +separately from the earlier pinned return. Byte-identical HTML implementation/tests/ +assets retain their prior browser, physical-touch, VoiceOver and calibration evidence +within its stated scope. The inherited approximately 11% streaming comparison and +existing disposition remain unchanged. No PR merge, tag or publication is performed. + + +## Final merge review: preserve the export lane + +Review against current main found that the earlier CLI reconciliation had dropped +its existing `export` lane and event-reader I/O diagnostics. Restore the complete +five-command export lane, including the native public consumer and three Apple SDK +probes, while retaining the structured combined focused inventory `[1, 39]`. +Restore missing-event error capture and both console/ABI parameter counts in receipts. +Library, CLI, assets, fixtures, product policy and all counters remain unchanged. +Fresh required hosted checks and actual export-lane execution qualify the harness +correction. Earlier source-bound implementation/WASI evidence is retained through +exact equivalence; no previous receipt is relabelled to the new head. diff --git a/Examples/Consumer/Sources/Consumer/READMEExamples.swift b/Examples/Consumer/Sources/Consumer/READMEExamples.swift index 124f41e..9da2562 100644 --- a/Examples/Consumer/Sources/Consumer/READMEExamples.swift +++ b/Examples/Consumer/Sources/Consumer/READMEExamples.swift @@ -115,20 +115,13 @@ func runREADMEExamples() throws { precondition(report.result.objectValue?["ready"] == true) precondition(report.statistics[.workUnits] > 0) - let interactiveHTML = try JSONHTMLRenderer(options: .init( - title: "Exact values", presentation: .interactive, - initiallyExpandedDepth: 2, outlineDepth: 3 - )).renderWithReport(value) + let valueForHTML = try JSONParser().parse(#"{"amount":9007199254740993,"note":"**Hello** $x_i^2$"}"#) + let interactiveHTML = try JSONHTMLRenderer().renderWithReport(valueForHTML) precondition(interactiveHTML.statistics.copyJSONBytes > 0) - - let exportReport = try JSONExportDocument.prepareWithReport(value) - let markdownItem = try exportReport.result.selecting([.markdown]) - let markdownBytes = try markdownItem.data(for: .markdown) - precondition(exportReport.operation[.nodes] > 0 && !markdownBytes.isEmpty) - - let richItem = try JSONExportDocument.prepare(value, options: .init( - formats: [.json, .plainText, .rtf] - )).selecting([.rtf]) - let richBytes = try richItem.data(for: .rtf) - precondition(!richBytes.isEmpty) + let staticHTML = try JSONHTMLRenderer(options: .init( + layout: .structured, presentation: .formatted, valueProfile: .complete) + ).render(valueForHTML) + let syntaxHTML = try JSONHTMLRenderer(options: .init( + layout: .syntax, presentation: .formatted) + ).render(valueForHTML) } diff --git a/Package.swift b/Package.swift index 2f888f3..ab6f0cc 100644 --- a/Package.swift +++ b/Package.swift @@ -27,7 +27,7 @@ let package = Package( .target(name: "JSONCLIEngine", dependencies: ["JSON", "JSONSchema", "JSONStreaming", "JSONPatch", "JSONHTML", "JSONFilterRuntime"]), .executableTarget(name: "JSONCLI", dependencies: ["JSONCLIEngine"]), .testTarget(name: "JSONCLITests", dependencies: ["JSONCLIEngine", "JSONFilterRuntime", "JSON", "JSONPatch", "JSONSchema", "JSONHTML"]), - .testTarget(name: "JSONHTMLTests", dependencies: ["JSONHTML", "JSON"]), + .testTarget(name: "JSONHTMLTests", dependencies: ["JSONHTML", "JSON"], resources: [.copy("Fixtures")]), .testTarget(name: "JSONTests", dependencies: ["JSON"], exclude: ["ImplementationPlan.md"]), .testTarget(name: "JSONSchemaTests", dependencies: ["JSONSchema"], exclude: ["ImplementationPlan.md"]), .testTarget(name: "JSONStreamingTests", dependencies: ["JSONStreaming"], exclude: ["ImplementationPlan.md"]), diff --git a/README.md b/README.md index 3fab35b..b5cb356 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Choose the products your application needs: | **JSON** | Exact values, strict parsing/writing, native Codable, Pointer, presence, checked construction, source documents and diagnostics | | **JSONSchema** | Draft 2020-12 compilation/evaluation, offline registries, bounded regex, annotations and raw declaration inspection | | **JSONStreaming** | Pull syntax events and values, strict NDJSON, root-array elements and awaited output chunks | -| **JSONHTML** | Exact HTML, offline exploration and immutable native transfer/export (1.3 development) | +| **JSONHTML** | Structured forms/cards, exact syntax, bounded native previews and prepared exports | | **swift-json** | Bounded command-line JSON workflows (1.4 development) | | **JSONBridges** | Opt-in native integer, Double and Foundation Decimal conversions (E2 development) | | **JSONPatch** | Staged RFC 6902 JSON Patch and RFC 7396 Merge Patch | @@ -381,70 +381,51 @@ hard RSS or throughput guarantees. Read the [resource contract](Documentation/Ar [machine-readable profiles](Documentation/Architecture/ResourceProfiles.json) and [49-cap ownership matrix](Documentation/Qualification/LimitsMatrix.json). -## Standalone HTML (1.3 development) +## Standalone HTML -The additive `JSONHTML` product renders complete highlighted HTML from the same -exact writer cursor. Formatted mode is script-free. Opt into interactive mode for -native disclosures, a container outline, case-sensitive literal search and exact -JSON/Pointer copy and downloads. All assets are embedded and hash-authorized; -no network access or browser JSON number conversion is needed. +`JSONHTML` presents the exact value as ordered object forms, indexed array cards and +small scalar flow lists. The direct renderer defaults to interactive structured +inspection. Static presentation is script-free; syntax is an explicit alternate. ```swift +import JSON import JSONHTML -let interactiveHTML = try JSONHTMLRenderer(options: .init( - title: "Exact values", presentation: .interactive, - initiallyExpandedDepth: 2, outlineDepth: 3 -)).renderWithReport(value) +let valueForHTML = try JSONParser().parse(#"{"amount":9007199254740993,"note":"**Hello** $x_i^2$"}"#) +let interactiveHTML = try JSONHTMLRenderer().renderWithReport(valueForHTML) precondition(interactiveHTML.statistics.copyJSONBytes > 0) +let staticHTML = try JSONHTMLRenderer(options: .init( + layout: .structured, presentation: .formatted, valueProfile: .complete) +).render(valueForHTML) +let syntaxHTML = try JSONHTMLRenderer(options: .init( + layout: .syntax, presentation: .formatted) +).render(valueForHTML) ``` -Every call shares one finite budget through admission, metadata, formatting and -final copying. Downloads use native UTF-8 bytes, preserving numeric lexemes and -scalar identity. Clipboard acceptance and manual copying depend on the browser; -printing includes the full formatted JSON independently of disclosure state. -Read the [formatted guide](Sources/JSONHTML/Documentation.docc/FormattedRendering.md) -and [interactive guide](Sources/JSONHTML/Documentation.docc/InteractiveRendering.md) -for limits and browser qualifications. Prepare immutable exact JSON, UTF-8 text, -Markdown and static HTML together, then explicitly select the offered format: - -```swift -let exportReport = try JSONExportDocument.prepareWithReport(value) -let markdownItem = try exportReport.result.selecting([.markdown]) -let markdownBytes = try markdownItem.data(for: .markdown) -precondition(exportReport.operation[.nodes] > 0 && !markdownBytes.isEmpty) -``` - -On Apple platforms, importing JSONHTML adds JSON's native Transferable conformance. -Only strict JSON imports; presentation formats export. Default preparation uses one -tab per level, LF and light static HTML, sharing one finite cumulative budget. -Read the [prepared export guide](Sources/JSONHTML/Documentation.docc/PreparedExport.md) -and [architecture contract](Documentation/Architecture/JSONHTMLExport.md) for format -selection, transport error wrapping and finite limits. Real receiving-app behavior -is qualified separately from API/file transport. Opt into styled RTF for prepared -items; its native decoded text preserves the pretty JSON body. Generation is -actor-independent and uses finite style/UTF-16 caps alongside shared core quotas. - -```swift -let richItem = try JSONExportDocument.prepare(value, options: .init( - formats: [.json, .plainText, .rtf] -)).selecting([.rtf]) -let richBytes = try richItem.data(for: .rtf) -precondition(!richBytes.isEmpty) -``` - -Prepare PDF explicitly on the main actor with `try await item.preparingPDF()` or -`try await item.preparingPDFWithReport()`. This uses the item's own static HTML; -success returns a new immutable item whose selected PDF transfers only completed -bytes. Defaults are paginated A4, 36-point margins and a light Menlo profile. -US Letter and an explicit continuous page are available. Finite limits and -cancellation cover owned operations; opaque SDK work and allocation are separate. -See the [PDF guide](Sources/JSONHTML/Documentation.docc/PDFPreparation.md). -Caller-supplied `JSONHTMLAnnotation` values attach diagnostics by exact typed path. -Rendering a coherent `JSONDocument` can show original value or parent UTF-8 byte -spans; unmapped or replaced roots invent none. Rendering does not execute schema -validation. See the [diagnostic guide](Sources/JSONHTML/Documentation.docc/DiagnosticAnnotations.md). -Published 1.2 versions do not include JSONHTML. +Inspection string prefixes are labelled and capped at 512 decoded scalars, 2048 UTF-8 +bytes and 512 escaped-display scalars. Original values and full JSON source remain +accessible without JavaScript. Keys, numbers and node structure remain complete. +Copy/download use exact native writer bytes. Complete printing expands all values +independently of collapsed screen state or selected tab. + +The bounded native preview dialect adds supported sRGB swatches, declared canonical +PNG/JPEG, star emphasis, inert links and atomic mini-math as MathML Core. It keeps +unsupported/malformed spans literal. There is no full Markdown/TeX engine, remote +media, raw markup or mandatory schema/browser dependency. Native image header admission +is distinct from browser raster decoding and process memory. + +Prepared export defaults to static structured complete values and light colors; +JSON/text/Markdown/RTF bytes and strict JSON import retain their contracts. Explicit +Apple PDF consumes prepared HTML, awaits fonts/media and reports its backend and +page strategy. Generation and PDF preparation each have an explicit cumulative budget; +transport, receiver and opaque SDK work are separate. + +Read the [structured preview guide](Sources/JSONHTML/Documentation.docc/StructuredPreviews.md), +[formatted syntax guide](Sources/JSONHTML/Documentation.docc/FormattedRendering.md), +[prepared export guide](Sources/JSONHTML/Documentation.docc/PreparedExport.md) and +[API/policy freeze](Documentation/Architecture/Release1.3/StructuredPreviews/APIAndPolicy.md). +The consolidated design revision is not a package release number. This feature branch +has its own qualification record; published versions retain their existing contract. ## Documentation and examples @@ -532,7 +513,7 @@ swift build -c release --product swift-json ### CLI development for 1.4 -The isolated 1.4 feature stack adds the `swift-json` executable beside the five +The isolated 1.4 feature stack adds the `swift-json` executable beside the six libraries, with no external root dependencies. Build from the feature source with Swift6.4+ (`swift build -c release --product swift-json`), or use `swift run swift-json help`. Native qualification requires macOS/SDK27+. diff --git a/Scripts/README.md b/Scripts/README.md index 3e703ac..3cb974c 100644 --- a/Scripts/README.md +++ b/Scripts/README.md @@ -9,7 +9,7 @@ separate from the dependency-free library. Native lanes fail on unsupported host | `python3 Scripts/preflight.py` | Actual native OS/compiler/SDK receipt | | `python3 Scripts/ci.py --lane native` | Manifests, builds, Swift Testing, consumers and suites | | `python3 Scripts/ci.py --lane safety` | Address/Thread Sanitizer and strict memory safety | -| `python3 Scripts/ci.py --lane linux` | Actual Linux preflight, all six products, exact root/cap/corpus/consumer counts | +| `python3 Scripts/ci.py --lane linux` | Actual Linux preflight, all seven products, exact root/cap/corpus/consumer counts | | `python3 Scripts/ci.py --lane linux-safety` | Linux Address/Thread Sanitizer and strict memory safety | | `python3 Scripts/ci.py --lane wasm` | Matched Swift 6.4 WASI debug/release, exact root/corpus/cap/consumer counts, two-host public parity and structured resource failures | | `python3 Scripts/qualify_linux_reproduction.py --output PATH` | Actual source archive, byte-equal rebuild, clean extraction, build/tests and public consumer | diff --git a/Scripts/ci.py b/Scripts/ci.py index ffc1b5a..2a92ad3 100644 --- a/Scripts/ci.py +++ b/Scripts/ci.py @@ -7,7 +7,9 @@ from testing_events import read_events, ROOT_PARAMETER_CASES ROOT = pathlib.Path(__file__).resolve().parents[1] -SHARED_TESTS = 'independentCodecFacadesAndExactValuesCanBeSharedAcrossTasks|concurrentSchemaEvaluationsHaveExclusiveDynamicRegexAndAnnotationState|sharedRegexProgramHasExclusiveConcurrentSearchState|immutableIndexedObjectCanBeSharedAcrossTasks|qaSharedPayloadAndRootHaveIndependentConcurrentReadScopes|sha256InputCapacityAndConcurrentInvocationsRemainIndependent|sharedFormattedHTMLRendererHasIndependentConcurrentInvocations|sharedAnnotatedDocumentRendererHasIndependentConcurrentInvocations|sharedExportPreparationsHaveIndependentConcurrentState|applePDFSimultaneousJobsHaveIndependentStateAndCancellation|sharedFilterPlanHasIndependentConcurrentInvocations|sharedProviderBridgeHasIndependentConcurrentInvocations|e2SharedBridgeInputsHaveInvocationOwnedState|e2SharedCompiledPlanFacadeHasIndependentInvocationState' +SHARED_TESTS = 'independentCodecFacadesAndExactValuesCanBeSharedAcrossTasks|concurrentSchemaEvaluationsHaveExclusiveDynamicRegexAndAnnotationState|sharedRegexProgramHasExclusiveConcurrentSearchState|immutableIndexedObjectCanBeSharedAcrossTasks|qaSharedPayloadAndRootHaveIndependentConcurrentReadScopes|sha256InputCapacityAndConcurrentInvocationsRemainIndependent|sharedFormattedHTMLRendererHasIndependentConcurrentInvocations|sharedFilterPlanHasIndependentConcurrentInvocations|structuredSharedRendererPlansHaveInvocationOwnedState|sharedExportPreparationsHaveIndependentConcurrentState|sharedAnnotatedDocumentRendererHasIndependentConcurrentInvocations|applePDFSimultaneousJobsHaveIndependentStateAndCancellation|sharedProviderBridgeHasIndependentConcurrentInvocations|e2SharedBridgeInputsHaveInvocationOwnedState|e2SharedCompiledPlanFacadeHasIndependentInvocationState' +ROOT_COUNTS = {'JSONTests':119,'JSONSchemaTests':69,'JSONStreamingTests':14,'JSONPatchTests':11,'JSONRegexRuntimeTests':12,'JSONHTMLTests':97 if sys.platform == 'darwin' else 81,'JSONCLITests':87,'JSONBridgesTests':10} +SHARED_COUNTS = {'JSONTests':4,'JSONSchemaTests':3,'JSONRegexRuntimeTests':1,'JSONHTMLTests':5 if sys.platform == 'darwin' else 4,'JSONCLITests':1,'JSONBridgesTests':1} R0_TESTS = 'readerProbe|indexProbes|duplicateRecognitionPrecedesValueAndClosingSyntax' def snapshot(): @@ -125,15 +127,16 @@ def materialized_outcomes(output): def check_result(command, code, log, events=None): counts = [int(n) for n in re.findall(r'Test run with (\d+) tests? in[^\n]*passed', log)] passed = code == 0 - if events is not None: - counts = events['bundle_counts'] + if events is not None: counts = events['bundle_counts'] if command[:2] == ['swift', 'test']: - expected = [85] if 'cli|sharedFilterPlan' in command else [38] if 'export|Export|apple' in command else [14] if 'Tools/WasmAudit' in command else [2, 3, 3, 11] if 'qa' in command else [7] if R0_TESTS in command else [3] if 'Tools/MCPReadiness' in command or 'Tools/MobileQualification' in command else [1] if 'Tools/Qualification' in command else sorted([1, 1, 3 if sys.platform == 'linux' else 4, 1, 3, 4]) if 'thread' in command else sorted([119, 69, 14, 11, 12, 61 if sys.platform == 'linux' else 75, 85, 10]) + expected = [87] if 'cli|sharedFilterPlan' in command else [1, 39] if 'export|Export|apple' in command else [14] if 'Tools/WasmAudit' in command else [2, 3, 3, 11] if 'qa' in command else [7] if R0_TESTS in command else [3] if 'Tools/MCPReadiness' in command or 'Tools/MobileQualification' in command else [1] if 'Tools/Qualification' in command else sorted(SHARED_COUNTS.values()) if 'thread' in command else sorted(ROOT_COUNTS.values()) passed = passed and sorted(counts) == expected + if 'thread' in command and events is not None: + passed = passed and events['module_function_counts'] == SHARED_COUNTS if '--filter' not in command and not any(x.startswith('Tools/') for x in command): if events is not None: - passed = passed and events['module_function_counts'] == {'JSONTests':119,'JSONSchemaTests':69,'JSONStreamingTests':14,'JSONPatchTests':11,'JSONRegexRuntimeTests':12,'JSONHTMLTests':61 if sys.platform == 'linux' else 75,'JSONCLITests':85,'JSONBridgesTests':10} - passed = passed and (events['parameterized_functions'] == ROOT_PARAMETER_CASES if events is not None else sum(int(n) for n in re.findall(r'with (\d+) test cases passed', log)) == 199) + passed = passed and events['module_function_counts'] == ROOT_COUNTS + passed = passed and (events['parameterized_functions'] == ROOT_PARAMETER_CASES if events is not None else sum(int(n) for n in re.findall(r'with (\d+) test cases passed', log)) == 325) if 'Tools/Qualification' in command: probes = re.findall(r'^CAP_PROBE (\w+) (\S+)$', log, re.MULTILINE) matrix = json.loads((ROOT / 'Documentation/Qualification/LimitsMatrix.json').read_text()) @@ -174,9 +177,9 @@ def check_result(command, code, log, events=None): if command[:3] == ['python3', '-B', 'Scripts/verify_html_assets.py']: passed = passed and 'JSONHTML_ASSETS passed count=2' in log.splitlines() if 'BrowserFixtures' in command: - passed = passed and 'JSONHTML_BROWSER_FIXTURES passed count=10' in log.splitlines() + passed = passed and 'JSONHTML_BROWSER_FIXTURES passed count=15' in log.splitlines() if command[:3] == ['python3', '-B', 'Scripts/qualify_html_browser.py']: - passed = passed and 'JSONHTML_BROWSER passed engines=3 cases=46 skips=0' in log.splitlines() + passed = passed and 'JSONHTML_BROWSER passed engines=3 cases=76 skips=0' in log.splitlines() return passed, counts def main(): @@ -249,7 +252,7 @@ def save(): if receipt['status'] == 'passed' and args.lane == 'wasm': try: wasi = json.loads((output / 'probes/wasi.json').read_text()) - if wasi['status'] != 'passed' or wasi['root_counts'] != {'JSONTests':119,'JSONBridgesTests':10,'JSONSchemaTests':69,'JSONRegexRuntimeTests':12,'JSONStreamingTests':14,'JSONPatchTests':11,'JSONHTMLTests':61}: + if wasi['status'] != 'passed' or wasi['root_counts'] != {'JSONTests':119,'JSONBridgesTests':10,'JSONSchemaTests':69,'JSONRegexRuntimeTests':12,'JSONStreamingTests':14,'JSONPatchTests':11,'JSONHTMLTests':81}: raise ValueError('Missing WASI qualification boundary') receipt['materialized_wasi'] = {'path':'probes/wasi.json','sha256':hashlib.sha256((output / 'probes/wasi.json').read_bytes()).hexdigest()} except (OSError,KeyError,ValueError,TypeError) as error: diff --git a/Scripts/qualify_cli_ownership.py b/Scripts/qualify_cli_ownership.py index 192e1c4..912af17 100644 --- a/Scripts/qualify_cli_ownership.py +++ b/Scripts/qualify_cli_ownership.py @@ -4,16 +4,31 @@ import argparse, hashlib, json, pathlib, subprocess ROOT = pathlib.Path(__file__).resolve().parents[1] +def built_package_name(): + # SwiftPM derives package access identity from the checkout directory. Read + # the actual native build settings rather than assuming a Swift-JSON folder. + names=set() + def visit(value): + if isinstance(value,dict): + if value.get('PRODUCT_MODULE_NAME')=='JSONFilterRuntime' and 'SWIFT_PACKAGE_NAME' in value: + names.add(value['SWIFT_PACKAGE_NAME']) + for child in value.values(): visit(child) + elif isinstance(value,list): + for child in value: visit(child) + visit(json.loads((ROOT/'.build/manifest.pif').read_text())) + if len(names)!=1: raise ValueError('Cannot resolve the built JSONFilterRuntime package identity') + return names.pop() + def main(): p=argparse.ArgumentParser();p.add_argument('--output',required=True);a=p.parse_args();output=ROOT/a.output;output.mkdir(parents=True,exist_ok=True) sdk=subprocess.check_output(['xcrun','--sdk','macosx','--show-sdk-path'],text=True).strip() - modules=ROOT/'.build/debug'; rows=[] + modules=ROOT/'.build/debug'; rows=[]; package_name=built_package_name() cases=[('borrowed-job','import JSONFilterRuntime\npackage func use(_ job: inout CLIJobContext) throws { try job.work() }\n',True,None), ('double-consume','import JSONFilterRuntime\npackage func twice(_ job: consuming CLIJobContext) { let first = consume job; let second = consume job; _ = consume first; _ = consume second }\n',False,'consumed more than once'), ('outside-private-package','import JSONFilterRuntime\nfunc make() throws { let job = try CLIJobContext(); _ = consume job }\n',False,'cannot find')] for name,source,positive,diagnostic in cases: file=output/(name+'.swift');file.write_text(source) - command=['xcrun','swiftc','-swift-version','6','-sdk',sdk,'-target','arm64-apple-macos27.0','-module-cache-path',str(output/'ModuleCache'),'-I',str(modules),'-package-name','swift_json' if name!='outside-private-package' else 'external_consumer','-c',str(file),'-o',str(output/(name+'.o'))] + command=['xcrun','swiftc','-swift-version','6','-sdk',sdk,'-target','arm64-apple-macos27.0','-module-cache-path',str(output/'ModuleCache'),'-I',str(modules),'-package-name',package_name if name!='outside-private-package' else 'external_consumer','-c',str(file),'-o',str(output/(name+'.o'))] r=subprocess.run(command,capture_output=True,text=True);log=output/(name+'.log');log.write_text((r.stdout+r.stderr).replace(str(ROOT),'.')) passed=r.returncode==0 if positive else r.returncode!=0 and diagnostic in r.stderr rows.append({'id':name,'command':[x.replace(str(ROOT),'.') for x in command],'exit_code':r.returncode,'expected_diagnostic':diagnostic,'status':'passed' if passed else 'failed','source_sha256':hashlib.sha256(source.encode()).hexdigest(),'log_sha256':hashlib.sha256(log.read_bytes()).hexdigest()}) diff --git a/Scripts/qualify_html_browser.py b/Scripts/qualify_html_browser.py index f0ece25..c1327a2 100644 --- a/Scripts/qualify_html_browser.py +++ b/Scripts/qualify_html_browser.py @@ -13,7 +13,7 @@ def main(): result = subprocess.run([node, str(ROOT / 'Tools/JSONHTMLBrowser/browser.cjs'), str(ROOT / args.fixtures), str(output)], cwd=ROOT / 'Tools/JSONHTMLBrowser') if result.returncode: return result.returncode receipt = json.loads((output / 'browser.json').read_text()) - if receipt['status'] != 'passed' or len(receipt['engines']) != 3 or len(receipt['cases']) != 46 or any(c['status'] != 'passed' for c in receipt['cases']) or receipt['unexpectedSkips']: + if receipt['status'] != 'passed' or len(receipt['engines']) != 3 or len(receipt['cases']) != 76 or any(c['status'] != 'passed' for c in receipt['cases']) or receipt['unexpectedSkips']: return 1 return 0 if __name__ == '__main__': sys.exit(main()) diff --git a/Scripts/testing_events.py b/Scripts/testing_events.py index e841074..0cda8b5 100644 --- a/Scripts/testing_events.py +++ b/Scripts/testing_events.py @@ -4,6 +4,11 @@ import json ROOT_PARAMETER_CASES = { + 'structuredCanonicalBase64MatchesPadBitGold(_:)': 16, + 'structuredInlineMatchesIndependentDialectCases(_:)': 32, + 'structuredMiniMathMatchesIndependentGold(_:)': 45, + 'structuredAllPreviewCeilingsRejectBeforeGrowthAndKeepInclusiveEdges(metric:)': 22, + 'cliHTMLPreviewFlagsRejectUsageBeforeInput(argv:)': 11, 'cliEveryCounterHasInclusiveCheckedBoundaryAndTerminalFailure(metric:)': 16, 'cliCompilerEnforcesCapsAtActualSourceTokenAndIRGrowth(metric:)': 4, 'cliQueryManyTinyRecordsDoNotRetainTheWholeInputJob(records:)': 2, diff --git a/Scripts/wasm_support.py b/Scripts/wasm_support.py index 31a67b3..c75aa83 100644 --- a/Scripts/wasm_support.py +++ b/Scripts/wasm_support.py @@ -7,7 +7,7 @@ ROOT = pathlib.Path(__file__).resolve().parents[1] SDK = 'swift-6.4.0-RELEASE_wasm' COUNTS = {'JSONTests': 119, 'JSONBridgesTests': 10, 'JSONSchemaTests': 69, 'JSONRegexRuntimeTests': 12, - 'JSONStreamingTests': 14, 'JSONPatchTests': 11, 'JSONHTMLTests': 61} + 'JSONStreamingTests': 14, 'JSONPatchTests': 11, 'JSONHTMLTests': 81} GROUPS = {'core': 6, 'resources': 1, 'schema': 2, 'streaming': 3, 'html': 6} def main(): @@ -53,7 +53,16 @@ def binary(scratch, config, name): run('compiler', [swift, '--version'], lambda c, t: c == 0 and 'swift-6.4-RELEASE' in t) run('sdk', [swift, 'sdk', 'list'], lambda c, t: c == 0 and SDK in t.splitlines()) run('wasmkit-version', [wasmkit, '--version']); run('node-version', [node, '--version']) - manifest = json.loads(run('root-manifest', [swift, 'package', 'dump-package'])) + manifest_text = run('root-manifest', [swift, 'package', 'dump-package']) + # SwiftPM can write its lock-wait diagnostic before JSON on the merged log. + # Accept only that precise preamble; the decoded manifest and trailing bytes + # remain exact. The full diagnostic is retained in the command receipt. + start = manifest_text.find('{') + if start < 0: raise ValueError('Missing dump-package JSON') + preamble = manifest_text[:start].strip() + if preamble and not re.fullmatch(r"Another instance of SwiftPM .*waiting until that process has finished execution\.\.\.", preamble): + raise ValueError('Unexpected manifest preamble: ' + preamble) + manifest = json.loads(manifest_text[start:]) if manifest['dependencies'] or sorted(p['name'] for p in manifest['products']) != ['JSON', 'JSONBridges', 'JSONHTML', 'JSONPatch', 'JSONSchema', 'JSONStreaming', 'swift-json']: raise ValueError('Changed public products/dependencies') def invoke(engine, path, mode='command', arguments=(), repo=True, resources=True): @@ -71,7 +80,7 @@ def invoke(engine, path, mode='command', arguments=(), repo=True, resources=True for name, count in COUNTS.items(): text = run('node-' + config + '-' + name, invoke('node', roots[name], 'tests'), tests(count)) invocations += sum(int(n) for n in re.findall(r'with (\d+) test cases passed', text)) - if invocations != 173: raise ValueError('Changed root parameterized discovery: ' + str(invocations)) + if invocations != 288: raise ValueError('Changed root parameterized discovery: ' + str(invocations)) for package, name, scratch in [('Tools/WasmAudit', 'WasmAudit', '.build/wasm-audit-consumer'), ('Tools/Qualification', 'Qualification', '.build/wasm-qualification'), ('Tools/MCPReadiness', 'MCPReadiness', '.build/wasm-readiness')]: diff --git a/Sources/JSONCLIEngine/CLIArguments.swift b/Sources/JSONCLIEngine/CLIArguments.swift index 327e076..e25824f 100644 --- a/Sources/JSONCLIEngine/CLIArguments.swift +++ b/Sources/JSONCLIEngine/CLIArguments.swift @@ -14,12 +14,12 @@ package struct CLIArguments: Sendable { private static let values: [String: Set] = [ "format": ["indent", "number-style"], "validate": ["schema", "resource", "max-issues"], "pointer": [], "patch": ["patch"], "stream": ["framing"], "schema": ["resource", "name", "description"], - "render": ["format", "presentation", "theme", "title", "html"], + "render": ["format", "presentation", "theme", "title", "html", "layout", "string-preview", "inline-formatting", "value-previews"], "query": ["filter-file", "input-framing", "indent", "number-style", "arg", "argjson", "rawfile", "slurpfile", "division-scale", "rounding"] ] private static let booleans: [String: Set] = [ "format": ["compact", "tab", "sort-keys"], "validate": [], "pointer": ["allow-missing"], "patch": [], - "stream": [], "schema": [], "render": [], + "stream": [], "schema": [], "render": ["full-values"], "query": ["null-input", "slurp", "raw-input", "compact", "tab", "sort-keys", "raw-output", "raw-output0", "join-output", "ascii-output", "exit-status", "unbuffered", "args", "jsonargs"] ] package init(_ argv: [String], admitted: Bool = false, context: inout CLIJobContext) throws { @@ -88,7 +88,15 @@ package struct CLIArguments: Sendable { guard ["html", "markdown", "text"].contains(format) else { throw CLIError(.usage, "capabilityUnavailable") } if let presentation = options["presentation"], !["static", "interactive"].contains(presentation) { throw CLIError(.usage, "invalidPresentation") } if let theme = options["theme"], !["system", "light", "dark"].contains(theme) { throw CLIError(.usage, "invalidTheme") } - if format != "html" && (options["presentation"] != nil || options["theme"] != nil || options["title"] != nil) { throw CLIError(.usage, "htmlOnlyOption") } + let htmlOnly = ["presentation", "theme", "title", "layout", "string-preview", "full-values", "inline-formatting", "value-previews"] + if format != "html" && htmlOnly.contains(where: { options[$0] != nil }) { throw CLIError(.usage, "htmlOnlyOption") } + if let v = options["layout"], !["structured", "syntax"].contains(v) { throw CLIError(.usage, "invalidLayout") } + if let v = options["inline-formatting"], !["off", "declared", "automatic"].contains(v) { throw CLIError(.usage, "invalidInlineFormatting") } + if let v = options["value-previews"], !["off", "standard"].contains(v) { throw CLIError(.usage, "invalidValuePreviews") } + if options["string-preview"] != nil && options["full-values"] != nil { throw CLIError(.usage, "conflictingValueProfile") } + if let v = options["string-preview"] { + guard !v.isEmpty, v.utf8.allSatisfy({ (48...57).contains($0) }), let n = UInt64(v), n < UInt64(Int.max), !n.multipliedReportingOverflow(by: 4).overflow else { throw CLIError(.usage, "invalidStringPreview") } + } } if command == "query" { if let framing = options["input-framing"], !["document", "values", "ndjson"].contains(framing) { throw CLIError(.usage, "invalidInputFraming") } diff --git a/Sources/JSONCLIEngine/CLIEngine.swift b/Sources/JSONCLIEngine/CLIEngine.swift index 43adeef..cdf6c77 100644 --- a/Sources/JSONCLIEngine/CLIEngine.swift +++ b/Sources/JSONCLIEngine/CLIEngine.swift @@ -74,7 +74,7 @@ package enum CLIEngine { } return result } - private static func execute(_ arguments: CLIArguments, context: inout CLIJobContext) throws -> Int32 { + package static func execute(_ arguments: CLIArguments, context: inout CLIJobContext) throws -> Int32 { if ["help", "--help", "-h"].contains(arguments.command) || arguments.hasHelp { let selected = arguments.hasHelp ? arguments.command : arguments.positionals.first guard arguments.hasHelp || arguments.positionals.count <= 1 else { throw CLIError(.usage, "helpArity") } @@ -176,7 +176,18 @@ package enum CLIEngine { default: throw CLIError(.usage, "invalidTheme") } try context.domain(.writing); try context.meter.resetValueBounds() - let options = JSONHTMLRenderingOptions(title: arguments.options["title"] ?? "JSON", theme: theme, limits: .init(core: context.meter.limits), presentation: presentation) + var options = JSONHTMLRenderingOptions(title: arguments.options["title"] ?? "JSON document", theme: theme, limits: .init(core: context.meter.limits), presentation: presentation) + options.layout = arguments.options["layout"] == "syntax" ? .syntax : .structured + options.valueProfile = arguments.options["full-values"] == nil ? .inspection : .complete + options.inlineFormatting = JSONHTMLInlineFormatting(rawValue: arguments.options["inline-formatting"] ?? "automatic")! + let previews = arguments.options["value-previews"] != "off" + options.colorPreviews = previews; options.embeddedImagePreviews = previews + if let value = arguments.options["string-preview"], let n = UInt64(value) { + let product = n.multipliedReportingOverflow(by: 4) + guard !product.overflow else { throw CLIError(.usage, "invalidStringPreview") } + options.previewPolicy.maxSourceScalars = n; options.previewPolicy.maxEscapedDisplayScalars = n + options.previewPolicy.maxPrefixUTF8Bytes = min(context.meter.limits[.stringBytes], product.partialValue) + } let html = try JSONHTMLRenderer(options: options).render(value, meter: &context.meter) try context.charge(.totalResults); try output.write(html.bytes, context: &context); output.resultAccepted(context: &context) } else if format == "text" || format == "markdown" { diff --git a/Sources/JSONCLIEngine/CLIHelp.swift b/Sources/JSONCLIEngine/CLIHelp.swift index db7fa7b..e442882 100644 --- a/Sources/JSONCLIEngine/CLIHelp.swift +++ b/Sources/JSONCLIEngine/CLIHelp.swift @@ -102,6 +102,11 @@ package enum CLIHelp { --format html|markdown|text (default html) HTML: --presentation static|interactive (default static) --theme system|light|dark --title TEXT + --layout structured|syntax (default structured) + --string-preview N | --full-values (default inspection: 512 scalars) + --inline-formatting off|declared|automatic (default automatic) + --value-previews off|standard (default standard; declared PNG/JPEG only) + HTML-only options cannot be used with text or Markdown. --html PATH is shorthand for HTML --output PATH. Static HTML is expanded and script-free. Text/Markdown use real tabs; Markdown wraps exact JSON in a JSON code fence. diff --git a/Sources/JSONHTML/Assets/Interactive.css b/Sources/JSONHTML/Assets/Interactive.css index abf9442..eaebdb1 100644 --- a/Sources/JSONHTML/Assets/Interactive.css +++ b/Sources/JSONHTML/Assets/Interactive.css @@ -1,2 +1,29 @@ /* SPDX-License-Identifier: Apache-2.0; Swift-JSON interactive asset v1 */ .interactive .toolbar{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;margin-bottom:1rem}.interactive button,.interactive input{font:inherit;padding:.35rem .55rem;border:1px solid currentColor;border-radius:.3rem;background:var(--bg);color:var(--fg)}.interactive :focus-visible{outline:3px solid var(--num);outline-offset:3px}.interactive .layout{display:grid;grid-template-columns:minmax(0,1fr) 18rem;gap:1.5rem}.interactive .layout:not(:has(.outline)){grid-template-columns:minmax(0,1fr)}.interactive .outline{max-height:70vh;overflow:auto;position:sticky;top:1rem;align-self:start}.interactive a{color:var(--num);overflow-wrap:anywhere}.interactive ul{padding-left:1.1rem}.interactive .node{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;line-height:1.7;overflow-wrap:anywhere}.interactive .children{padding-left:1.25rem;border-left:1px solid var(--fg);margin-left:.35rem}.interactive summary{cursor:pointer}.interactive .pointer-label{font-size:.8em;opacity:.85;margin-inline:.5rem}.interactive:not(.enhanced) .pointer-actions{display:none}.interactive .pointer-actions{display:inline-flex;gap:.3rem;flex-wrap:wrap}.interactive .pointer-actions button{font-size:.75rem;padding:.1rem .3rem}.interactive .search-hit{outline:2px solid var(--num);outline-offset:2px}.interactive #jh-status{min-height:1.7rem}.interactive .manual-copy textarea{display:block;width:100%;min-height:8rem;font-family:monospace}.interactive .print-json{display:none}.interactive [hidden]{display:none!important}.interactive code{white-space:pre-wrap}.interactive .search-results{max-height:16rem;overflow:auto}.interactive .kind{font-weight:600}@media(max-width:700px){body.interactive{padding:1rem}.interactive .layout{grid-template-columns:1fr}.interactive .outline{position:static;max-height:15rem}.interactive .pointer-actions{display:flex}.interactive .children{padding-left:.75rem}}@media print{.interactive .toolbar,.interactive .layout,.interactive .manual-copy,.interactive #jh-status,.interactive .search-results,.interactive noscript{display:none!important}.interactive .print-json{display:block!important}.interactive .print-json pre{white-space:pre-wrap;overflow:visible}} +/* Structured forms/cards and native previews. Fixed original asset v2. */ +.rich{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.5;max-width:88rem;margin:auto;padding:2rem;--card:#fff;--line:#d3dce8;--muted:#46566b;--chip:#edf4ff;--string:#174075;--number:#134c27;--bool:#683817;--null:#4b466e} +.rich.dark{--card:#182438;--line:#43526a;--muted:#bbc9dc;--chip:#233753;--string:#c2deff;--number:#b8e4c4;--bool:#ffd8b8;--null:#d9ceff} +.rich .document-header{border-bottom:1px solid var(--line);margin-bottom:1rem}.rich .document-header h1{font-size:1.6rem;margin:.2rem 0}.rich .document-header p{color:var(--muted);font-size:.9rem}.rich .view-tabs{display:flex;gap:.6rem;margin:1rem 0}.rich .view-tabs a{border:1px solid var(--line);border-radius:.45rem;padding:.45rem .8rem;text-decoration:none}.rich [aria-selected=true]{background:var(--chip);font-weight:650} +.rich .layout{grid-template-columns:minmax(0,1fr) 16rem}.rich .node{font-family:inherit;min-width:0;overflow-wrap:anywhere}.rich .container{background:var(--card);border:1px solid var(--line);border-radius:.65rem;padding:.7rem;margin:.2rem 0 .6rem}.rich summary{cursor:pointer;line-height:1.6}.rich summary .kind{color:var(--muted);font-size:.83rem}.rich .container.depth-0{padding:1rem}.rich .container.depth-1{border-inline-start:3px solid #789ac3}.rich .container.depth-2{border-inline-start:3px solid #809d8b}.rich .container.depth-3{border-inline-start:3px solid #9b8ead} +.rich .object-form{margin:.6rem 0 0}.rich .field-row{display:grid;grid-template-columns:minmax(7rem,23%) minmax(0,1fr);gap:.6rem;border-top:1px solid var(--line);padding:.6rem 0}.rich dt,.rich dd{margin:0;min-width:0}.rich dt{color:var(--muted)}.rich .key{white-space:pre-wrap;overflow-wrap:anywhere;font-size:.85rem}.rich .scalar{padding:.2rem .45rem;border-radius:.35rem;background:var(--chip);white-space:normal}.rich .scalar.string{color:var(--string)}.rich .scalar.number{color:var(--number)}.rich .scalar.bool{color:var(--bool)}.rich .scalar.null{color:var(--null)}.rich .display-value{white-space:pre-wrap;overflow-wrap:anywhere}.rich code{font-size:.9em}.rich .pointer-actions{margin-inline:.4rem}.rich .pointer-actions button{font-size:.72rem} +.rich .indexed-list{list-style:none;padding:0;margin:.6rem 0 0;display:grid;gap:.65rem}.rich .record-card{border:1px solid var(--line);border-radius:.55rem;padding:.65rem;min-width:0}.rich .index-label{display:block;font-size:.8rem;color:var(--muted);font-weight:650;margin-bottom:.35rem}.rich .scalar-flow{display:flex;flex-wrap:wrap;list-style:none;padding:0;gap:.4rem}.rich .flow-item{min-width:0;max-width:100%}.rich .visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}.rich .original-value{margin:.4rem 0;color:var(--fg);border-top:1px dashed var(--line);padding-top:.4rem}.rich .original-value code{display:block;white-space:pre-wrap;overflow-wrap:anywhere}.rich .preview-label,.rich .preview-kind{display:block;font-size:.78rem;color:var(--muted);margin:.25rem 0}.rich .color-swatch{display:block;width:7.5rem;height:3rem;border:1px solid var(--line);border-radius:.4rem;margin:.3rem 0}.rich .image-preview{margin:.5rem 0}.rich .image-preview img{display:block;max-width:100%;height:auto;object-fit:contain}.rich figcaption{font-size:.78rem;color:var(--muted)}.rich math{font-size:1.12em;max-width:100%;overflow-x:auto;vertical-align:middle}.rich .inert-link,.rich .inert-image{overflow-wrap:anywhere}.rich .exact-source{margin:1rem 0}.rich .print-json{display:none}.rich dialog{width:min(90vw,44rem);background:var(--bg);color:var(--fg);border:1px solid var(--line);border-radius:.5rem}.rich dialog::backdrop{background:#0008}.rich .manual-copy textarea{box-sizing:border-box}.rich .search-hit{outline:2px solid var(--num);outline-offset:2px}.rich pre{white-space:pre-wrap;overflow-wrap:anywhere;overflow:visible}.rich a:focus-visible,.rich button:focus-visible,.rich input:focus-visible,.rich select:focus-visible{outline:3px solid var(--num);outline-offset:3px} +@media(prefers-color-scheme:dark){.rich.automatic{--card:#182438;--line:#43526a;--muted:#bbc9dc;--chip:#233753;--string:#c2deff;--number:#b8e4c4;--bool:#ffd8b8;--null:#d9ceff}} +@media(max-width:700px){.rich,.rich.interactive{padding:.8rem}.rich .layout{grid-template-columns:minmax(0,1fr)}.rich .field-row{grid-template-columns:minmax(0,1fr);gap:.3rem}.rich .container.depth-0{padding:.55rem}.rich .container,.rich .record-card{padding:.45rem}.rich .outline{position:static}} +@media(prefers-reduced-motion:reduce){.rich *{scroll-behavior:auto!important;animation:none!important;transition:none!important}} +@media(forced-colors:active){.rich .scalar,.rich .container,.rich .record-card{background:Canvas;color:CanvasText;border-color:CanvasText}.rich .color-swatch{forced-color-adjust:none}.rich .kind,.rich .preview-kind,.rich .preview-label,.rich .index-label{color:CanvasText}} +@media print{.rich .screen-view,.rich .document-header,.rich .exact-source,.rich .view-tabs,.rich .preview-issues,.rich .toolbar,.rich #jh-status,.rich .search-results,.rich dialog,.rich noscript{display:none!important}.rich .print-json{display:block!important}.rich .print-json .container{border:1px solid #aaa;break-inside:auto}.rich .print-json .record-card{break-inside:avoid}.rich{padding:0;max-width:none;color:#111;background:#fff;--card:#fff;--line:#bbb;--muted:#333;--chip:#f6f6f6;--string:#173353;--number:#154524;--bool:#4b2611;--null:#494158}.rich .original-value{display:block}.rich .image-preview img{max-height:22cm;width:auto;max-width:100%}.rich .print-json .object-form{display:block}.rich .print-json summary{list-style:none}} +.rich pre code{unicode-bidi:plaintext;direction:ltr}.rich .container.depth-3{padding:0;border:0;margin:.2rem 0;border-radius:0}.rich dialog[open]{display:block!important} +.rich .scalar-kind{font-size:.72rem;color:var(--muted);margin-inline-end:.4rem;font-weight:600}.rich .display-value,.rich .key,.rich .original-value code,.rich .inert-link,.rich .inert-image{unicode-bidi:isolate;direction:ltr}.rich .depth-3 .field-row{grid-template-columns:minmax(0,1fr)} +.rich .color-swatch{max-width:100%;height:auto;aspect-ratio:5/2}.rich .view-tabs{flex-wrap:wrap}.rich .pointer-actions{max-width:100%;min-width:0}.rich button,.rich input,.rich select{max-width:100%;min-width:0;box-sizing:border-box}.rich button{white-space:normal;overflow-wrap:anywhere} + +@media print{.rich .source-download{display:none!important}} +.rich .scalar-item{display:flex;align-items:flex-start;gap:.55rem;min-width:0;padding:.3rem 0;border-top:1px solid var(--line)}.rich .scalar-item>.index-label{flex:none;min-width:2.5rem;margin:0;padding-top:.2rem}.rich .scalar-item>.node{min-width:0;flex:1} + +/* Approved responsive transitions; all deep record rails stop at bucket 3. */ +.rich a{color:var(--num)} +.rich .depth-3 .record-card{padding:0;border:0;margin:0;border-radius:0} +.rich .depth-3 .indexed-list{gap:.2rem} +@media(max-width:1200px){.rich,.rich.interactive{padding:1.25rem}.rich .document-header h1{font-size:1.4rem}} +@media(max-width:920px){.rich .layout{grid-template-columns:minmax(0,1fr)}.rich .outline{position:static;max-height:12rem}.rich .document-header{display:block}} +@media(min-width:581px) and (max-width:700px){.rich .field-row{grid-template-columns:minmax(7rem,23%) minmax(0,1fr);gap:.6rem}} +@media(max-width:580px){.rich,.rich.interactive{padding:.8rem}.rich .field-row{grid-template-columns:minmax(0,1fr);gap:.3rem}.rich #jh-query{flex:1 1 100%;width:100%}} diff --git a/Sources/JSONHTML/Assets/Interactive.js b/Sources/JSONHTML/Assets/Interactive.js index 45f431a..74e2c9b 100644 --- a/Sources/JSONHTML/Assets/Interactive.js +++ b/Sources/JSONHTML/Assets/Interactive.js @@ -12,6 +12,32 @@ const MAX_RESULTS = 100; let searchEpoch = 0; let bulkEpoch = 0; + let activeView = 'structured'; + let hitIDs = [], hitIndex = -1, copyFocus; + const tabs = document.getElementById('jh-tabs'); + const views = ['structured', 'syntax']; + const activate = (view, focus = false) => { + const changed = activeView !== view; + activeView = view; + if (!tabs) return; + for (const name of views) { + const tab = document.getElementById('jh-tab-' + name); + const pane = document.getElementById('jh-' + name); + const selected = name === view; + tab.setAttribute('aria-selected', String(selected)); tab.tabIndex = selected ? 0 : -1; + pane.hidden = !selected; + if (focus && selected) tab.focus(); + } + const label = document.querySelector('label[for="jh-query"]'); + label.textContent = view === 'syntax' ? 'Search exact JSON source' : 'Search previews'; + if (changed) search(); + }; + const moveHit = delta => { + if (!hitIDs.length) return; + hitIndex = hitIndex < 0 ? (delta < 0 ? hitIDs.length - 1 : 0) : (hitIndex + delta + hitIDs.length) % hitIDs.length; + reveal(hitIDs[hitIndex]); + say('Result ' + (hitIndex + 1) + ' of ' + hitIDs.length + ' listed matches (maximum 100).'); + }; const bytes = encoded => { const text = atob(encoded); const result = new Uint8Array(text.length); @@ -22,7 +48,8 @@ const say = message => { status.textContent = message; }; const reveal = (id, focus = true) => { const node = document.getElementById(id); - if (!node || !tree.contains(node)) return; + if (!node || !(tree.contains(node) || node.id === 'jh-syntax')) return; + if (tabs) activate(node.id === 'jh-syntax' ? 'syntax' : 'structured'); for (let parent = node.parentElement; parent; parent = parent.parentElement) { if (parent.tagName === 'DETAILS') parent.open = true; } @@ -40,21 +67,25 @@ setTimeout(() => URL.revokeObjectURL(url), 1000); say('Download requested: ' + filename + '.'); }; - const copy = async (encoded, label) => { + const copy = async (encoded, label, opener) => { const value = text(encoded); - manual.hidden = true; + copyFocus = opener || document.activeElement; + if (manual.open) manual.close(); + else manual.hidden = true; try { if (!navigator.clipboard || !navigator.clipboard.writeText) throw new Error('Clipboard unavailable'); await navigator.clipboard.writeText(value); say('Browser accepted ' + label + ' copy. Platform clipboard transformations may differ.'); } catch (_) { manual.hidden = false; manualText.value = value; + if (manual.showModal) manual.showModal(); manualText.focus(); manualText.select(); - say('Clipboard unavailable or rejected. Select the text and copy manually, or download exact UTF-8 bytes.'); + say(value.length === 0 ? 'Root Pointer is empty. Clipboard unavailable or rejected; download exact UTF-8 bytes.' : 'Clipboard unavailable or rejected. Select the text and copy manually, or download exact UTF-8 bytes.'); } }; const bulk = open => { const epoch = ++bulkEpoch; + if (tabs && activeView === 'syntax') { say('The exact JSON source view has no container disclosures.'); return; } const nodes = tree.querySelectorAll('details.node'); let i = 0; const step = () => { @@ -69,7 +100,7 @@ // Full DOM remains present. Scanning is chunked, not a browser CPU/heap quota. const search = () => { const epoch = ++searchEpoch; - results.replaceChildren(); + results.replaceChildren(); hitIDs = []; hitIndex = -1; const needle = query.value; const byteLimit = Number(query.dataset.byteLimit); let issue = ''; @@ -82,17 +113,19 @@ if (new TextEncoder().encode(needle).length > byteLimit) issue = 'Search query exceeds the UTF-8 byte limit.'; } const accepted = issue ? '' : needle; - const candidates = tree.querySelectorAll('.node'); + const candidates = activeView === 'syntax' && tabs ? [document.getElementById('jh-syntax')] : tree.querySelectorAll('.node'); let i = 0, matches = 0, listed = 0; const step = () => { if (epoch !== searchEpoch) return; const end = Math.min(i + CHUNK, candidates.length); for (; i < end; i++) { const node = candidates[i]; - const fields = node.tagName === 'DETAILS' ? node.querySelector('summary').querySelectorAll('[data-search]') : node.querySelectorAll(':scope > [data-search]'); + const fields = node.id === 'jh-syntax' ? node.querySelectorAll('[data-source-search]') : node.tagName === 'DETAILS' ? node.querySelector('summary').querySelectorAll('[data-search]') : node.querySelectorAll(':scope > [data-search]'); + const key = node.parentElement.tagName === 'DD' ? node.parentElement.previousElementSibling : null; let matched = false; for (const field of fields) { if (accepted.length > 0 && field.textContent.includes(accepted)) matched = true; } - node.classList.toggle('search-hit', matched); + if (key && accepted.length && key.textContent.includes(accepted)) matched = true; + node.classList.toggle('search-hit', matched && listed < MAX_RESULTS); if (!matched) continue; matches++; if (listed < MAX_RESULTS) { @@ -100,13 +133,14 @@ const link = document.createElement('a'); link.href = '#' + node.id; link.dataset.target = node.id; - link.textContent = 'Match ' + matches + ': ' + text(node.dataset.pointerEscaped); + link.textContent = 'Match ' + matches + ': ' + (node.dataset.pointerEscaped ? text(node.dataset.pointerEscaped) : 'Exact JSON source'); + hitIDs.push(node.id); item.append(link); results.append(item); listed++; } } if (i < candidates.length) setTimeout(step, 0); else { - say(issue || matches + ' matching nodes in rendered JSON key/value text. ' + listed + ' results listed (maximum ' + MAX_RESULTS + ').'); + say(issue || matches + ' matching nodes in ' + (activeView === 'syntax' ? 'exact JSON source' : 'displayed key/value previews') + '. ' + listed + ' results listed (maximum ' + MAX_RESULTS + ').'); const first = results.querySelector('a'); if (first) reveal(first.dataset.target, false); } @@ -114,6 +148,31 @@ step(); }; query.addEventListener('input', search); + query.addEventListener('keydown', event => { + if (event.key === 'Escape') { event.preventDefault(); query.value = ''; search(); } + if (event.key === 'Enter') { event.preventDefault(); moveHit(event.shiftKey ? -1 : 1); } + }); + if (tabs) { + tabs.setAttribute('role', 'tablist'); tabs.setAttribute('aria-label', 'JSON view'); + for (const view of views) { + const tab = document.getElementById('jh-tab-' + view), pane = document.getElementById('jh-' + view); + tab.setAttribute('role', 'tab'); tab.setAttribute('aria-controls', pane.id); + pane.setAttribute('role', 'tabpanel'); pane.setAttribute('aria-labelledby', tab.id); + tab.addEventListener('click', event => { event.preventDefault(); activate(view); search(); }); + tab.addEventListener('keydown', event => { + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); + activate(event.key === 'Home' ? 'structured' : event.key === 'End' ? 'syntax' : view === 'syntax' ? 'structured' : 'syntax', true); search(); + }); + } + activate(document.getElementById('jh-structured').dataset.initialView); + } + const theme = document.getElementById('jh-theme'); + if (theme) { + theme.value = document.body.classList.contains('dark') ? 'dark' : document.body.classList.contains('light') ? 'light' : 'automatic'; + theme.addEventListener('change', () => { for (const value of ['automatic', 'light', 'dark']) document.body.classList.toggle(value, value === theme.value); }); + } + if (manual.tagName === 'DIALOG') manual.addEventListener('close', () => { if (copyFocus && copyFocus.isConnected) copyFocus.focus(); }); document.addEventListener('click', event => { const link = event.target.closest('a[data-target]'); if (link) { event.preventDefault(); reveal(link.dataset.target); return; } @@ -121,23 +180,26 @@ if (!button) return; event.preventDefault(); event.stopPropagation(); const action = button.dataset.action; + if (action === 'next') { moveHit(1); return; } + if (action === 'previous') { moveHit(-1); return; } + if (action === 'close-copy') { manual.close(); return; } if (action === 'expand') { bulk(true); return; } if (action === 'collapse') { bulk(false); return; } if (action === 'clear') { query.value = ''; search(); return; } if (action === 'select') { manualText.focus(); manualText.select(); return; } if (action === 'copy-json' || action === 'download-json') { const encoded = document.getElementById('jh-copy-json').dataset.bytes; - if (action === 'copy-json') void copy(encoded, 'JSON'); + if (action === 'copy-json') void copy(encoded, 'JSON', button); else download(encoded, 'document.json'); return; } const node = document.getElementById(button.dataset.node); if (!node) return; - if (action === 'copy-pointer') void copy(node.dataset.pointer, 'raw RFC 6901 Pointer'); - else if (action === 'copy-pointer-escaped') void copy(node.dataset.pointerEscaped, 'Pointer JSON string'); + if (action === 'copy-pointer') void copy(node.dataset.pointer, 'raw RFC 6901 Pointer', button); + else if (action === 'copy-pointer-escaped') void copy(node.dataset.pointerEscaped, 'Pointer JSON string', button); else if (action === 'download-pointer') download(node.dataset.pointer, 'pointer.txt'); }); document.body.classList.add('enhanced'); document.getElementById('jh-toolbar').hidden = false; - say('Interactive controls ready. Search scope: rendered JSON key/value text.'); + say('Interactive controls ready. Search scope: selected displayed previews or exact JSON source.'); })(); diff --git a/Sources/JSONHTML/Documentation.docc/FormattedRendering.md b/Sources/JSONHTML/Documentation.docc/FormattedRendering.md index ef52ccf..eb852b8 100644 --- a/Sources/JSONHTML/Documentation.docc/FormattedRendering.md +++ b/Sources/JSONHTML/Documentation.docc/FormattedRendering.md @@ -7,7 +7,7 @@ import JSON import JSONHTML let value = try JSONParser().parse(#"{"amount":9007199254740993,"note":null}"#) -let renderer = JSONHTMLRenderer(options: .init(title: "Exact values")) +let renderer = JSONHTMLRenderer(options: .init(layout: .syntax, presentation: .formatted, title: "Exact values")) let report = try renderer.renderWithReport(value) let bytes = report.result let work = report.statistics.operation[.workUnits] @@ -22,7 +22,7 @@ It uses system fonts and makes no network request or browser number conversion. Space indentation defaults to two units. Set `indentationUnit: .tabs` to emit actual horizontal tabs; `tabWidth` controls visual display independently. Zero units still produces expanded lines. All roots are supported, including a quoted -string, JSON null and empty containers. There is no truncation or partial success. +string, JSON null and empty containers. The syntax layout is complete and returns no partial success. Structured inspection has labelled prefixes; see . Numeric output uses preserved source lexemes where available. Neither browser floating-point conversion nor semantic equality substitutes for exact output @@ -50,5 +50,5 @@ This API formats values only. A source document can be rendered through its `root`; it does not imply preservation of source whitespace or source locations. Browser accessibility, printing and the complete supported-platform matrix need their separate later qualification. Native transfer and prepared exports, including opt-in styled RTF, are documented -in . PDF remains a later increment. See +in . Explicit asynchronous PDF consumes the prepared artifact; see . See for the separately selected interactive presentation. diff --git a/Sources/JSONHTML/Documentation.docc/InteractiveRendering.md b/Sources/JSONHTML/Documentation.docc/InteractiveRendering.md index db18941..c424b83 100644 --- a/Sources/JSONHTML/Documentation.docc/InteractiveRendering.md +++ b/Sources/JSONHTML/Documentation.docc/InteractiveRendering.md @@ -13,7 +13,7 @@ let renderer = JSONHTMLRenderer(options: .init( let report = try renderer.renderWithReport(root) ``` -Formatted remains the default. Containers use native `details`/`summary` elements +Structured interactive inspection is the direct renderer default; layout and presentation are independent. Containers use native `details`/`summary` elements without exclusive accordion grouping. Root is level zero; levels below the initial depth start open. Outline depth includes containers through that level; nil omits it. Generated ordinal identifiers are independent of keys and Pointer spelling. @@ -38,7 +38,7 @@ manual text area. Browsers and platform clipboards may transform text, and texta normalize line endings. UTF-8 downloads preserve the independently tested bytes; “download requested” does not claim that a receiving application has saved them. -Printing uses a second complete formatted block, hidden on screen and shown in print +Printing uses a second complete representation in the configured layout, hidden on screen and shown in print media. Collapse/search state does not truncate it. Both copies, the original native JSON buffer, Pointer forms, Base64 metadata, outline records and final result copy share one cumulative meter. Root admission occurs once. Additional ceilings bound @@ -57,4 +57,4 @@ Firefox and WebKit cases using file/local HTTP, scripting on/off, keyboard, sear exact downloads, clipboard outcomes, mobile viewports and complete print content. Playwright WebKit is not native Safari; viewports are not physical device tests. Native Safari, VoiceOver and the final 1.3 platform matrix remain separate release -qualification. Annotation/source mapping and transfer/RTF/PDF APIs are not included. +qualification. Typed diagnostics/source mapping and prepared transfer/RTF/PDF APIs are documented in , and . diff --git a/Sources/JSONHTML/Documentation.docc/JSONHTML.md b/Sources/JSONHTML/Documentation.docc/JSONHTML.md index 2872566..9f0e9c3 100644 --- a/Sources/JSONHTML/Documentation.docc/JSONHTML.md +++ b/Sources/JSONHTML/Documentation.docc/JSONHTML.md @@ -25,6 +25,7 @@ browser heap, browser instruction quotas, process RSS or printing behavior. ### Guide +- - - - diff --git a/Sources/JSONHTML/Documentation.docc/StructuredPreviews.md b/Sources/JSONHTML/Documentation.docc/StructuredPreviews.md new file mode 100644 index 0000000..2b87dc8 --- /dev/null +++ b/Sources/JSONHTML/Documentation.docc/StructuredPreviews.md @@ -0,0 +1,59 @@ +# Structured layout and value previews + +Inspect exact JSON as ordered forms, indexed record cards or wrapping scalar lists. +Layout is independent of static or interactive presentation. + +```swift +import JSON +import JSONHTML + +let value = try JSONParser().parse(#"{"message":"**Hello** $x_i^2$","amount":9007199254740993}"#) +let options = JSONHTMLRenderingOptions(layout: .structured, presentation: .formatted) +let report = try JSONHTMLRenderer(options: options).renderWithReport(value) +``` + +Direct rendering defaults to structured, interactive inspection. Formatted presentation +is script-free. Prepared exports and direct transfer use structured, static complete +values and light colors. Explicit `.syntax` emits full highlighted native JSON and +runs no preview pipeline. The original initializer function shapes remain available. + +Inspection prefixes are labelled and bounded independently by 512 decoded Unicode +scalars, 2048 UTF-8 bytes and 512 escaped-display scalars. A scalar or control escape +is never split. Keys, exact numbers and node structure remain complete. Native +Original value disclosures and the complete JSON pane/source work without JavaScript. +Complete values and the print representation expand full original strings. Derived +inline previews still obey their source-prefix and destination eligibility limits. Copy and +download use native exact writer bytes, not a JavaScript JSON model. + +Declare typed paths through ``JSONHTMLPreviewHint``. Literal declarations suppress +inference; explicit hints precede caller-translated effective schema content +annotations. Rendering does not validate schema or treat preview failures as schema +invalidity. Missing/wrong-kind declarations create bounded preview issues without +creating values. Duplicate paths in one tier are invalid configuration. + +Native previews support complete sRGB hex/rgb/rgba strings, explicitly unit-labelled +numeric channels and declared canonical PNG/JPEG. Geometry/header/pixel and aggregate +checks precede emission; admission is not a full raster codec or a browser RSS cap. +Remote images, SVG input and arbitrary markup remain unavailable. Channel text makes +swatches understandable independently of color. + +The small inline dialect supports star bold/italic, inline links/images and atomic +mini-math. Links are inert unless ``JSONHTMLLinkActivation/httpAndHTTPS`` is explicitly +selected and the absolute destination passes validation. Backticks, fences and +block-looking text remain protected. Mini math emits allowlisted native MathML Core; +unknown, malformed, ambiguous or cutoff expressions remain entirely literal. There +is no document Markdown engine, full TeX engine, browser conversion or external parser. + +``JSONHTMLPreviewPolicy`` adds finite eligibility ceilings to unchanged core/HTML +limits. Staging, all duplicate source/print bodies, decoding, assets and result copies +share one invocation meter. Local rejection releases live scratch without refunding +work. Global exhaustion, cancellation and invalid configuration are terminal and +return no partial artifact. ``JSONHTMLStatistics`` separates unique JSON values, +conservative DOM bounds, preview issues and logical raster bytes. + +Use for immutable transport artifacts and +for the explicit asynchronous Apple backend. It consumes already-prepared bytes, +awaits fonts/images and retains the selected layout. Structured pagination uses +measured line/media whitespace; an unbreakable oversized fragment fails rather than +switching to syntax. Native/browser/font/assistive technology observations are +qualified separately; package budgets do not meter browser or receiver instructions. diff --git a/Sources/JSONHTML/JSONExportDocument.swift b/Sources/JSONHTML/JSONExportDocument.swift index 5b618c2..8462036 100644 --- a/Sources/JSONHTML/JSONExportDocument.swift +++ b/Sources/JSONHTML/JSONExportDocument.swift @@ -22,6 +22,8 @@ extension JSONExportReport: Sendable where Result: Sendable {} /// uses one cumulative meter. Transport and receiving-app work are outside it. public struct JSONExportDocument: Sendable { private let artifacts: [JSONExportFormat: Data] + /// Immutable policy of the already-prepared HTML; no source or renderer is retained. + public let htmlPolicy: JSONHTMLPreparedPolicy? private let maximumRepresentationBytes: UInt64 private let maximumPreparedBytes: UInt64 /// Exact artifact identifiers retained by this item, including hidden artifacts. @@ -31,7 +33,8 @@ public struct JSONExportDocument: Sendable { /// Validated filename component; extensions are added per representation. public let filenameStem: String - private init(artifacts: [JSONExportFormat: Data], advertised: Set, filename: String, representationLimit: UInt64, preparedLimit: UInt64) { + private init(artifacts: [JSONExportFormat: Data], advertised: Set, filename: String, representationLimit: UInt64, preparedLimit: UInt64, htmlPolicy: JSONHTMLPreparedPolicy? = nil) { + self.htmlPolicy = htmlPolicy maximumRepresentationBytes = representationLimit; maximumPreparedBytes = preparedLimit self.artifacts = artifacts; availableFormats = Set(artifacts.keys) advertisedFormats = advertised; filenameStem = filename @@ -88,7 +91,7 @@ public struct JSONExportDocument: Sendable { for format in JSONExportFormat.allCases where formats.contains(format) && !availableFormats.contains(format) { throw JSONExportFailure.unavailableFormat(format) } - return .init(artifacts: artifacts, advertised: formats, filename: filenameStem, representationLimit: maximumRepresentationBytes, preparedLimit: maximumPreparedBytes) + return .init(artifacts: artifacts, advertised: formats, filename: filenameStem, representationLimit: maximumRepresentationBytes, preparedLimit: maximumPreparedBytes, htmlPolicy: htmlPolicy) } package static func prepare(_ value: JSON, options: JSONExportOptions, meter: inout JSONWorkMeter) throws -> JSONExportReport { @@ -156,9 +159,15 @@ public struct JSONExportDocument: Sendable { if options.formats.contains(.html) { var htmlLimits = options.htmlLimits htmlLimits.maxDocumentBytes = min(htmlLimits.maxDocumentBytes, options.limits.maxRepresentationBytes) - let renderer = JSONHTMLRenderer(options: .init(title: options.title, theme: options.theme, + var rendering = JSONHTMLRenderingOptions(title: options.title, theme: options.theme, indentation: options.indentation, indentationUnit: options.indentationUnit, - tabWidth: options.tabWidth, limits: htmlLimits)) + tabWidth: options.tabWidth, limits: htmlLimits, presentation: .formatted, outlineDepth: nil) + rendering.layout = options.layout; rendering.valueProfile = options.valueProfile + rendering.printValueProfile = options.printValueProfile; rendering.inlineFormatting = options.inlineFormatting + rendering.colorPreviews = options.colorPreviews; rendering.embeddedImagePreviews = options.embeddedImagePreviews + rendering.linkActivation = options.linkActivation; rendering.previewHints = options.previewHints + rendering.effectivePreviewHints = options.effectivePreviewHints; rendering.previewPolicy = options.previewPolicy + let renderer = JSONHTMLRenderer(options: rendering) let html = try renderer.renderAdmitted(value, meter: &meter) try install(html.bytes, as: .html, meter: &meter); try meter.release(UInt64(html.bytes.count)) } @@ -169,7 +178,7 @@ public struct JSONExportDocument: Sendable { try meter.retain(16); try meter.work() try meter.release(titleBytes) try meter.checkpoint() - return .init(result: .init(artifacts: artifacts, advertised: options.formats, filename: options.filenameStem, representationLimit: options.limits.maxRepresentationBytes, preparedLimit: options.limits.maxPreparedBytes), + return .init(result: .init(artifacts: artifacts, advertised: options.formats, filename: options.filenameStem, representationLimit: options.limits.maxRepresentationBytes, preparedLimit: options.limits.maxPreparedBytes, htmlPolicy: options.formats.contains(.html) ? .init(options: options) : nil), operation: meter.statistics(), representationBytes: counts, preparedBytes: aggregate) } @@ -183,7 +192,7 @@ public struct JSONExportDocument: Sendable { package func addingPDF(_ pdf: Data) -> Self { var copy = artifacts; copy[.pdf] = pdf return .init(artifacts: copy, advertised: advertisedFormats.union([.pdf]), filename: filenameStem, - representationLimit: maximumRepresentationBytes, preparedLimit: maximumPreparedBytes) + representationLimit: maximumRepresentationBytes, preparedLimit: maximumPreparedBytes, htmlPolicy: htmlPolicy) } private static func writing(_ value: JSON, indentation: Int?, options: JSONExportOptions, meter: inout JSONWorkMeter) throws -> [UInt8] { diff --git a/Sources/JSONHTML/JSONExportOptions.swift b/Sources/JSONHTML/JSONExportOptions.swift index 03041ce..196338b 100644 --- a/Sources/JSONHTML/JSONExportOptions.swift +++ b/Sources/JSONHTML/JSONExportOptions.swift @@ -94,6 +94,27 @@ public struct JSONExportOptions: Sendable { /// HTML-specific ceilings; its core limits intersect the export core without resetting work. public var htmlLimits: JSONHTMLLimits + /// Independent HTML shape; defaults to ordered structured forms/cards. + public var layout: JSONHTMLLayout = .structured + /// Prepared static HTML defaults to full values. + public var valueProfile: JSONHTMLValueProfile = .complete + /// Explicit PDF/print string profile; complete by default. + public var printValueProfile: JSONHTMLValueProfile = .complete + /// Closed bounded inline string dialect. + public var inlineFormatting: JSONHTMLInlineFormatting = .automatic + /// Enable complete supported sRGB previews. + public var colorPreviews: Bool = true + /// Enable declared canonical PNG/JPEG previews. + public var embeddedImagePreviews: Bool = true + /// Default inert link policy. + public var linkActivation: JSONHTMLLinkActivation = .disabled + /// Explicit typed instance declarations. + public var previewHints: [JSONHTMLPreviewHint] = [] + /// Caller-translated effective content annotations; independent of schema validity. + public var effectivePreviewHints: [JSONHTMLPreviewHint] = [] + /// Finite local preview eligibility policy, additional to the cumulative core meter. + public var previewPolicy: JSONHTMLPreviewPolicy = .init() + /// Creates preparation options with four native artifacts and one tab per level. /// - Parameters: /// - formats: Requested exact artifact identifiers. diff --git a/Sources/JSONHTML/JSONHTMLInline.swift b/Sources/JSONHTML/JSONHTMLInline.swift new file mode 100644 index 0000000..23af805 --- /dev/null +++ b/Sources/JSONHTML/JSONHTMLInline.swift @@ -0,0 +1,610 @@ +// SPDX-License-Identifier: Apache-2.0 +import JSON + +package enum JSONHTMLInlineToken { + case text(Range) + case escaped(Int) + case open(String) + case close(String) + case math(JSONHTMLMathTree) + case link(String, [JSONHTMLInlineToken], Bool) + case image(JSONHTMLRaster, String) + case imageReference(String, String) +} +package struct JSONHTMLInlinePlan { + package let source: [Unicode.Scalar] + package let tokens: [JSONHTMLInlineToken] + package let formatted: Bool + package let reservation: UInt64 + package func emit(output: inout JSONHTMLDocumentBuffer, meter: inout JSONWorkMeter) throws { + enum Step { + case token(JSONHTMLInlineToken) + case closeLink + } + var stack: [Step] = [] + func push(_ v: Step, _ m: inout JSONWorkMeter) throws { + try m.retain(64) + stack.append(v) + } + for t in tokens.reversed() { + try meter.work() + try push(.token(t), &meter) + } + while let step = stack.popLast() { + try meter.release(64) + try meter.work() + switch step { + case .closeLink: try output.append("", meter: &meter) + case .token(let t): + switch t { + case .text(let r): + for i in r { + try meter.work() + try JSONHTMLDisplay.text( + String(source[i]), quoted: false, output: &output, meter: &meter) + } + case .escaped(let i): + try JSONHTMLDisplay.text( + String(source[i]), quoted: false, output: &output, meter: &meter) + case .open(let tag): + try output.addDOM(2, meter: &meter) + try output.append("<" + tag + ">", meter: &meter) + case .close(let tag): try output.append("", meter: &meter) + case .math(let tree): try tree.emit(output: &output, meter: &meter) + case .link(let destination, let label, let active): + try output.addDOM(4, meter: &meter) + if active { + try output.append( + "", meter: &meter) + try push(.closeLink, &meter) + } else { + try output.append("", meter: &meter) + try JSONHTMLDisplay.text( + destination, quoted: false, output: &output, meter: &meter) + try output.append(" · ", meter: &meter) + } + for v in label.reversed() { + try meter.work() + try push(.token(v), &meter) + } + case .image(let image, let alt): + try image.emit(alt: alt, output: &output, meter: &meter) + try output.addDOM(2, meter: &meter) + try output.append("", meter: &meter) + try JSONHTMLDisplay.text(alt, quoted: false, output: &output, meter: &meter) + try output.append("", meter: &meter) + case .imageReference(let alt, let destination): + try output.addDOM(2, meter: &meter) + try output.append( + "Image reference (not loaded): ", meter: &meter) + try JSONHTMLDisplay.text(alt, quoted: false, output: &output, meter: &meter) + try output.append(" · ", meter: &meter) + try JSONHTMLDisplay.text( + destination, quoted: false, output: &output, meter: &meter) + try output.append("", meter: &meter) + } + } + } + } +} +private struct InlineFrame { let marker: Int, start: Int, token: Int } +package enum JSONHTMLInlineScanner { + package static func scan( + _ source: String, options: JSONHTMLRenderingOptions, ledger: inout JSONHTMLMediaLedger, + meter: inout JSONWorkMeter + ) throws -> JSONHTMLInlinePlan { + let entry = meter.used(.retainedBytes) + var chars: [Unicode.Scalar] = [] + for c in source.unicodeScalars { + try meter.work() + try meter.retain(4) + chars.append(c) + } + let beforeBody = meter.used(.retainedBytes) + let previousLedger = ledger + let result: ([JSONHTMLInlineToken], Bool) + do { + result = try scanBody( + chars, options: options, labelOnly: false, ledger: &ledger, meter: &meter) + } catch is JSONHTMLPreviewRejection { + try meter.release(meter.used(.retainedBytes) - beforeBody) + ledger = previousLedger + try meter.retain(64) + result = ([.text(0.. ([JSONHTMLInlineToken], Bool) { + var tokens: [JSONHTMLInlineToken] = [] + var frames: [InlineFrame] = [] + var i = 0 + var formatted = false + func append(_ t: JSONHTMLInlineToken, _ m: inout JSONWorkMeter) throws { + try m.work() + guard UInt64(tokens.count) < options.previewPolicy.maxInlineTokens else { + throw JSONHTMLPreviewRejection.eligibility + } + try m.retain(64) + tokens.append(t) + } + func plain(_ range: Range, _ m: inout JSONWorkMeter) throws { + if !range.isEmpty { try append(.text(range), &m) } + } + func isSpace(_ c: Unicode.Scalar) -> Bool { + c == " " || c == "\n" || c == "\r" || c == "\t" + } + func slice(_ r: Range, _ m: inout JSONWorkMeter) throws -> String { + var s = "" + for n in r { + try m.work() + for _ in chars[n].utf8 { + try m.work() + try m.retain(1) + } + s.unicodeScalars.append(chars[n]) + } + return s + } + while i < chars.count { + try meter.work() + // Block-looking lines and fence/backtick ranges remain intact literal source. + let lineStart = i == 0 || chars[i - 1] == "\n" + var listMarker = false + if lineStart && (48...57).contains(chars[i].value) { + var n = i + while n < chars.count && (48...57).contains(chars[n].value) { + try meter.work() + n += 1 + } + listMarker = n + 1 < chars.count && chars[n] == "." && isSpace(chars[n + 1]) + } + let indentedBlock = + lineStart + && (chars[i] == "\t" + || i + 3 < chars.count && chars[i] == " " && chars[i + 1] == " " + && chars[i + 2] == " " + && chars[i + 3] == " ") + if indentedBlock + || lineStart + && (chars[i] == "#" || chars[i] == ">" || chars[i] == "|" + || ((chars[i] == "-" || chars[i] == "+" || chars[i] == "*") + && i + 1 < chars.count + && isSpace(chars[i + 1])) + || listMarker) + { + var end = i + while end < chars.count && chars[end] != "\n" { + try meter.work() + end += 1 + } + try plain(i..= 3) || at == 0 || chars[at - 1] == "\n") + { + end = at + run + break + } + at += run + } else { + at += 1 + } + } + try plain(i.. i + 1 && !isSpace(chars[i + 1]) && !isSpace(chars[close - 1]) + && (close + 1 == chars.count || chars[close + 1] != "$") + if valid { + let interior = try slice((i + 1).. 0 { + try meter.work() + let c = chars[at] + if c == "\\", at + 1 < chars.count, + "()\\".unicodeScalars.contains(chars[at + 1]) + { + at += 1 + let c = chars[at] + if UInt64(destination.utf8.count) + UInt64(c.utf8.count) + > options.previewPolicy.maxDestinationUTF8Bytes + { + valid = false + } + if valid { + for _ in c.utf8 { + try meter.work() + try meter.retain(1) + } + destination.unicodeScalars.append(c) + } + at += 1 + continue + } + if c == "(" { + depth += 1 + if UInt64(depth) > options.previewPolicy.maxInlineNesting { + valid = false + } + } + if c == ")" { + depth -= 1 + if depth == 0 { + at += 1 + break + } + } + if JSONHTMLDisplay.protected(c) || isSpace(c) || c == "\"" { valid = false } + if UInt64(destination.utf8.count) + UInt64(c.utf8.count) + > options.previewPolicy.maxDestinationUTF8Bytes + { + valid = false + } + if valid { + for _ in c.utf8 { + try meter.work() + try meter.retain(1) + } + destination.unicodeScalars.append(c) + } + at += 1 + } + var label: [Unicode.Scalar] = [] + var compound = false + for n in labelStart.. 0 && !isSpace(chars[i - 1]) { + while let frame = frames.last, remaining >= frame.marker { + try meter.work() + frames.removeLast() + try meter.release(32) + try append(.close(frame.marker == 2 ? "strong" : "em"), &meter) + formatted = true + remaining -= frame.marker + i += frame.marker + } + } + while remaining > 0 { + let width = remaining >= 2 ? 2 : 1 + if i + remaining < chars.count && !isSpace(chars[i + remaining]) + && UInt64(frames.count) < options.previewPolicy.maxInlineNesting + { + try meter.retain(32) + frames.append(.init(marker: width, start: i, token: tokens.count)) + try append(.open(width == 2 ? "strong" : "em"), &meter) + } else { + try plain(i..<(i + width), &meter) + } + i += width + remaining -= width + } + if i == start { i += run } + continue + } + let start = i + i += 1 + while i < chars.count && !"*[$!\\`\n".unicodeScalars.contains(chars[i]) { + try meter.work() + i += 1 + } + try plain(start.. Bool { + let entry = meter.used(.retainedBytes) + var bytes: [UInt8] = [] + for b in source.utf8 { + try meter.work() + guard b > 32, b < 127, !Array("<>\\\"`{}|".utf8).contains(b) else { + try meter.release(meter.used(.retainedBytes) - entry) + return false + } + try meter.retain(1) + bytes.append(b) + } + defer { bytes = [] } + let result = try urlBytes(bytes, meter: &meter) + try meter.release(UInt64(bytes.count)) + return result + } + private static func urlBytes(_ bytes: [UInt8], meter: inout JSONWorkMeter) throws -> Bool { + var start = 0 + if bytes.prefix(7).map({ $0 >= 65 && $0 <= 90 ? $0 + 32 : $0 }) == Array("http://".utf8) { + start = 7 + } else if bytes.prefix(8).map({ $0 >= 65 && $0 <= 90 ? $0 + 32 : $0 }) + == Array("https://".utf8) + { + start = 8 + } else { + return false + } + var end = start + while end < bytes.count && ![47, 63, 35].contains(bytes[end]) { + try meter.work() + end += 1 + } + guard end > start else { return false } + for b in bytes[start.. start + 1 else { return false } + for b in bytes[(start + 1).. start else { return false } + var labelStart = start + for n in start...hostEnd { + try meter.work() + if n == hostEnd || bytes[n] == 46 { + guard n > labelStart, n - labelStart <= 63, bytes[labelStart] != 45, + bytes[n - 1] != 45 + else { return false } + labelStart = n + 1 + } else { + guard + (48...57).contains(bytes[n]) || (65...90).contains(bytes[n]) + || (97...122).contains(bytes[n]) || bytes[n] == 45 + else { return false } + } + } + } + var i = end + while i < bytes.count { + try meter.work() + if bytes[i] == 37 { + guard i + 2 < bytes.count else { return false } + for b in bytes[(i + 1)...(i + 2)] { + guard (48...57).contains(b) || (65...70).contains(b) || (97...102).contains(b) + else { + return false + } + } + i += 3 + } else { + i += 1 + } + } + return true + } + private static func port(_ bytes: ArraySlice, meter: inout JSONWorkMeter) throws -> Bool + { + guard !bytes.isEmpty, bytes.count <= 5 else { return false } + var n: UInt64 = 0 + for b in bytes { + try meter.work() + guard (48...57).contains(b) else { return false } + n = n * 10 + UInt64(b - 48) + } + return n > 0 && n <= 65535 + } +} diff --git a/Sources/JSONHTML/JSONHTMLInteractive.swift b/Sources/JSONHTML/JSONHTMLInteractive.swift index b47aa00..40cf003 100644 --- a/Sources/JSONHTML/JSONHTMLInteractive.swift +++ b/Sources/JSONHTML/JSONHTMLInteractive.swift @@ -2,161 +2,14 @@ import JSON extension JSONHTMLRenderer { - package func renderInteractive(_ value: JSON, annotations: [JSONHTMLBoundAnnotation], meter: inout JSONWorkMeter) throws -> JSONHTMLDocumentBuffer { - let writing = JSONWritingOptions(limits: options.limits.core, indentation: options.indentation, - numberStyle: .preserved, indentationUnit: options.indentationUnit) - var copy = try nativeBytes(value, options: writing, meter: &meter) - var output = JSONHTMLDocumentBuffer(limits: options.limits) - output.setCopyBytes(UInt64(copy.count)) - try output.addDOM(96, meter: &meter) - try output.append("", meter: &meter) - try output.escaped(options.title, meter: &meter) - try output.append("

", meter: &meter) - try output.escaped(options.title, meter: &meter) - try output.append("

    ", meter: &meter) - try meter.release(UInt64(copy.count)); copy = [] - - var path: [JSONLocationComponent] = [] - var steps: [InteractiveStep] = [] - var outline: [OutlineEntry] = [] - func push(_ step: InteractiveStep, meter: inout JSONWorkMeter) throws { - try meter.retain(JSONLogicalWeights.frame); steps.append(step) - } - try output.append("
    ", meter: &meter) - try push(.node(value, nil, 0), meter: &meter) - var ordinal: UInt64 = 0 - while let step = steps.popLast() { - try meter.release(JSONLogicalWeights.frame); try meter.work() - switch step { - case .close(let container, let popPath): - try output.append(container ? "
    " : "", meter: &meter) - if popPath { path.removeLast(); try meter.release(JSONLogicalWeights.frame) } - case .node(let node, let component, let level): - if let component { try meter.retain(JSONLogicalWeights.frame); path.append(component) } - try output.addNode(meter: &meter) - let id = ordinal - ordinal = try JSONHTMLDocumentBuffer.adding(.renderedJSONNodes, ordinal, 1, options.limits.maxRenderedJSONNodes) - try output.addDOM(32, meter: &meter) - let container: Bool - switch node { case .array, .object: container = true; default: container = false } - if container, let depth = options.outlineDepth, level <= depth { - try output.addOutline(meter: &meter) - try meter.retain(40); outline.append(.init(ordinal: id, component: component, level: level)) - } - try output.append(container ? "
    " : ">", meter: &meter) - try label(component, output: &output, meter: &meter, searchable: true) - switch node { - case .object(let members): - try output.append(" object (", meter: &meter) - try output.decimal(UInt64(members.count), meter: &meter) - try output.append(" members)", meter: &meter) - case .array(let values): - try output.append(" array (", meter: &meter) - try output.decimal(UInt64(values.count), meter: &meter) - try output.append(" items)", meter: &meter) - default: - try output.append(" ", meter: &meter) - try highlighted(node, options: writing, output: &output, meter: &meter) - try output.append("", meter: &meter) - } - try output.append(" ", meter: &meter) - for byte in quotedPointer { try output.escaped(byte, meter: &meter) } - try meter.release(UInt64(quotedPointer.count)); quotedPointer = [] - try output.append("", meter: &meter) - for (action, title) in [("copy-pointer", "Copy Pointer (RFC 6901)"), ("copy-pointer-escaped", "Copy Pointer as JSON string"), ("download-pointer", "Download Pointer (UTF-8)")] { - try meter.work() - try output.append("", meter: &meter) - } - try output.append("", meter: &meter) - try push(.close(container, component != nil), meter: &meter) - switch node { - case .object(let members): - try output.append("
    \n", meter: &meter) - try meter.checkpoint() - return output - } - - private func nativeBytes(_ value: JSON, options: JSONWritingOptions, meter: inout JSONWorkMeter) throws -> [UInt8] { + package func nativeBytes(_ value: JSON, options: JSONWritingOptions, meter: inout JSONWorkMeter) throws -> [UInt8] { var cursor = JSONWritingCursor(value: value, options: options), bytes: [UInt8] = [] while let byte = try cursor.next(meter: &meter) { try meter.work(); try meter.charge(.outputBytes); try meter.retain(1); bytes.append(byte) } return bytes } - private func highlighted(_ value: JSON, options: JSONWritingOptions, output: inout JSONHTMLDocumentBuffer, meter: inout JSONWorkMeter) throws { + package func highlighted(_ value: JSON, options: JSONWritingOptions, output: inout JSONHTMLDocumentBuffer, meter: inout JSONWorkMeter) throws { var cursor = JSONWritingCursor(value: value, options: options) var active: JSONWritingTokenKind? while let token = try cursor.nextToken(meter: &meter) { @@ -174,7 +27,7 @@ extension JSONHTMLRenderer { } if let active, active != .whitespace { try output.append("", meter: &meter) } } - private func label(_ component: JSONLocationComponent?, output: inout JSONHTMLDocumentBuffer, meter: inout JSONWorkMeter, searchable: Bool) throws { + package func label(_ component: JSONLocationComponent?, output: inout JSONHTMLDocumentBuffer, meter: inout JSONWorkMeter, searchable: Bool) throws { switch component { case .key(let key): try output.append(searchable ? "" : "", meter: &meter) @@ -186,7 +39,7 @@ extension JSONHTMLRenderer { case nil: try output.append("Root", meter: &meter) } } - private func pointerBytes(_ path: [JSONLocationComponent], meter: inout JSONWorkMeter) throws -> [UInt8] { + package func pointerBytes(_ path: [JSONLocationComponent], meter: inout JSONWorkMeter) throws -> [UInt8] { try meter.require(.pointerTokens, UInt64(path.count)) var result: [UInt8] = [] func emit(_ byte: UInt8, meter: inout JSONWorkMeter) throws { @@ -211,16 +64,6 @@ extension JSONHTMLRenderer { } } -private enum InteractiveStep { - case node(JSON, JSONLocationComponent?, Int) - case close(Bool, Bool) -} -private struct OutlineEntry { - let ordinal: UInt64 - let component: JSONLocationComponent? - let level: Int -} - extension JSONHTMLDocumentBuffer { // Reserve the complete fixed decimal scratch before any owned formatting. package mutating func decimal(_ value: UInt64, meter: inout JSONWorkMeter) throws { diff --git a/Sources/JSONHTML/JSONHTMLInteractiveAssets.swift b/Sources/JSONHTML/JSONHTMLInteractiveAssets.swift index 7b014ac..bc719a4 100644 --- a/Sources/JSONHTML/JSONHTMLInteractiveAssets.swift +++ b/Sources/JSONHTML/JSONHTMLInteractiveAssets.swift @@ -5,6 +5,33 @@ package enum JSONHTMLInteractiveAssets { package static let css = #""" /* SPDX-License-Identifier: Apache-2.0; Swift-JSON interactive asset v1 */ .interactive .toolbar{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;margin-bottom:1rem}.interactive button,.interactive input{font:inherit;padding:.35rem .55rem;border:1px solid currentColor;border-radius:.3rem;background:var(--bg);color:var(--fg)}.interactive :focus-visible{outline:3px solid var(--num);outline-offset:3px}.interactive .layout{display:grid;grid-template-columns:minmax(0,1fr) 18rem;gap:1.5rem}.interactive .layout:not(:has(.outline)){grid-template-columns:minmax(0,1fr)}.interactive .outline{max-height:70vh;overflow:auto;position:sticky;top:1rem;align-self:start}.interactive a{color:var(--num);overflow-wrap:anywhere}.interactive ul{padding-left:1.1rem}.interactive .node{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;line-height:1.7;overflow-wrap:anywhere}.interactive .children{padding-left:1.25rem;border-left:1px solid var(--fg);margin-left:.35rem}.interactive summary{cursor:pointer}.interactive .pointer-label{font-size:.8em;opacity:.85;margin-inline:.5rem}.interactive:not(.enhanced) .pointer-actions{display:none}.interactive .pointer-actions{display:inline-flex;gap:.3rem;flex-wrap:wrap}.interactive .pointer-actions button{font-size:.75rem;padding:.1rem .3rem}.interactive .search-hit{outline:2px solid var(--num);outline-offset:2px}.interactive #jh-status{min-height:1.7rem}.interactive .manual-copy textarea{display:block;width:100%;min-height:8rem;font-family:monospace}.interactive .print-json{display:none}.interactive [hidden]{display:none!important}.interactive code{white-space:pre-wrap}.interactive .search-results{max-height:16rem;overflow:auto}.interactive .kind{font-weight:600}@media(max-width:700px){body.interactive{padding:1rem}.interactive .layout{grid-template-columns:1fr}.interactive .outline{position:static;max-height:15rem}.interactive .pointer-actions{display:flex}.interactive .children{padding-left:.75rem}}@media print{.interactive .toolbar,.interactive .layout,.interactive .manual-copy,.interactive #jh-status,.interactive .search-results,.interactive noscript{display:none!important}.interactive .print-json{display:block!important}.interactive .print-json pre{white-space:pre-wrap;overflow:visible}} +/* Structured forms/cards and native previews. Fixed original asset v2. */ +.rich{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.5;max-width:88rem;margin:auto;padding:2rem;--card:#fff;--line:#d3dce8;--muted:#46566b;--chip:#edf4ff;--string:#174075;--number:#134c27;--bool:#683817;--null:#4b466e} +.rich.dark{--card:#182438;--line:#43526a;--muted:#bbc9dc;--chip:#233753;--string:#c2deff;--number:#b8e4c4;--bool:#ffd8b8;--null:#d9ceff} +.rich .document-header{border-bottom:1px solid var(--line);margin-bottom:1rem}.rich .document-header h1{font-size:1.6rem;margin:.2rem 0}.rich .document-header p{color:var(--muted);font-size:.9rem}.rich .view-tabs{display:flex;gap:.6rem;margin:1rem 0}.rich .view-tabs a{border:1px solid var(--line);border-radius:.45rem;padding:.45rem .8rem;text-decoration:none}.rich [aria-selected=true]{background:var(--chip);font-weight:650} +.rich .layout{grid-template-columns:minmax(0,1fr) 16rem}.rich .node{font-family:inherit;min-width:0;overflow-wrap:anywhere}.rich .container{background:var(--card);border:1px solid var(--line);border-radius:.65rem;padding:.7rem;margin:.2rem 0 .6rem}.rich summary{cursor:pointer;line-height:1.6}.rich summary .kind{color:var(--muted);font-size:.83rem}.rich .container.depth-0{padding:1rem}.rich .container.depth-1{border-inline-start:3px solid #789ac3}.rich .container.depth-2{border-inline-start:3px solid #809d8b}.rich .container.depth-3{border-inline-start:3px solid #9b8ead} +.rich .object-form{margin:.6rem 0 0}.rich .field-row{display:grid;grid-template-columns:minmax(7rem,23%) minmax(0,1fr);gap:.6rem;border-top:1px solid var(--line);padding:.6rem 0}.rich dt,.rich dd{margin:0;min-width:0}.rich dt{color:var(--muted)}.rich .key{white-space:pre-wrap;overflow-wrap:anywhere;font-size:.85rem}.rich .scalar{padding:.2rem .45rem;border-radius:.35rem;background:var(--chip);white-space:normal}.rich .scalar.string{color:var(--string)}.rich .scalar.number{color:var(--number)}.rich .scalar.bool{color:var(--bool)}.rich .scalar.null{color:var(--null)}.rich .display-value{white-space:pre-wrap;overflow-wrap:anywhere}.rich code{font-size:.9em}.rich .pointer-actions{margin-inline:.4rem}.rich .pointer-actions button{font-size:.72rem} +.rich .indexed-list{list-style:none;padding:0;margin:.6rem 0 0;display:grid;gap:.65rem}.rich .record-card{border:1px solid var(--line);border-radius:.55rem;padding:.65rem;min-width:0}.rich .index-label{display:block;font-size:.8rem;color:var(--muted);font-weight:650;margin-bottom:.35rem}.rich .scalar-flow{display:flex;flex-wrap:wrap;list-style:none;padding:0;gap:.4rem}.rich .flow-item{min-width:0;max-width:100%}.rich .visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}.rich .original-value{margin:.4rem 0;color:var(--fg);border-top:1px dashed var(--line);padding-top:.4rem}.rich .original-value code{display:block;white-space:pre-wrap;overflow-wrap:anywhere}.rich .preview-label,.rich .preview-kind{display:block;font-size:.78rem;color:var(--muted);margin:.25rem 0}.rich .color-swatch{display:block;width:7.5rem;height:3rem;border:1px solid var(--line);border-radius:.4rem;margin:.3rem 0}.rich .image-preview{margin:.5rem 0}.rich .image-preview img{display:block;max-width:100%;height:auto;object-fit:contain}.rich figcaption{font-size:.78rem;color:var(--muted)}.rich math{font-size:1.12em;max-width:100%;overflow-x:auto;vertical-align:middle}.rich .inert-link,.rich .inert-image{overflow-wrap:anywhere}.rich .exact-source{margin:1rem 0}.rich .print-json{display:none}.rich dialog{width:min(90vw,44rem);background:var(--bg);color:var(--fg);border:1px solid var(--line);border-radius:.5rem}.rich dialog::backdrop{background:#0008}.rich .manual-copy textarea{box-sizing:border-box}.rich .search-hit{outline:2px solid var(--num);outline-offset:2px}.rich pre{white-space:pre-wrap;overflow-wrap:anywhere;overflow:visible}.rich a:focus-visible,.rich button:focus-visible,.rich input:focus-visible,.rich select:focus-visible{outline:3px solid var(--num);outline-offset:3px} +@media(prefers-color-scheme:dark){.rich.automatic{--card:#182438;--line:#43526a;--muted:#bbc9dc;--chip:#233753;--string:#c2deff;--number:#b8e4c4;--bool:#ffd8b8;--null:#d9ceff}} +@media(max-width:700px){.rich,.rich.interactive{padding:.8rem}.rich .layout{grid-template-columns:minmax(0,1fr)}.rich .field-row{grid-template-columns:minmax(0,1fr);gap:.3rem}.rich .container.depth-0{padding:.55rem}.rich .container,.rich .record-card{padding:.45rem}.rich .outline{position:static}} +@media(prefers-reduced-motion:reduce){.rich *{scroll-behavior:auto!important;animation:none!important;transition:none!important}} +@media(forced-colors:active){.rich .scalar,.rich .container,.rich .record-card{background:Canvas;color:CanvasText;border-color:CanvasText}.rich .color-swatch{forced-color-adjust:none}.rich .kind,.rich .preview-kind,.rich .preview-label,.rich .index-label{color:CanvasText}} +@media print{.rich .screen-view,.rich .document-header,.rich .exact-source,.rich .view-tabs,.rich .preview-issues,.rich .toolbar,.rich #jh-status,.rich .search-results,.rich dialog,.rich noscript{display:none!important}.rich .print-json{display:block!important}.rich .print-json .container{border:1px solid #aaa;break-inside:auto}.rich .print-json .record-card{break-inside:avoid}.rich{padding:0;max-width:none;color:#111;background:#fff;--card:#fff;--line:#bbb;--muted:#333;--chip:#f6f6f6;--string:#173353;--number:#154524;--bool:#4b2611;--null:#494158}.rich .original-value{display:block}.rich .image-preview img{max-height:22cm;width:auto;max-width:100%}.rich .print-json .object-form{display:block}.rich .print-json summary{list-style:none}} +.rich pre code{unicode-bidi:plaintext;direction:ltr}.rich .container.depth-3{padding:0;border:0;margin:.2rem 0;border-radius:0}.rich dialog[open]{display:block!important} +.rich .scalar-kind{font-size:.72rem;color:var(--muted);margin-inline-end:.4rem;font-weight:600}.rich .display-value,.rich .key,.rich .original-value code,.rich .inert-link,.rich .inert-image{unicode-bidi:isolate;direction:ltr}.rich .depth-3 .field-row{grid-template-columns:minmax(0,1fr)} +.rich .color-swatch{max-width:100%;height:auto;aspect-ratio:5/2}.rich .view-tabs{flex-wrap:wrap}.rich .pointer-actions{max-width:100%;min-width:0}.rich button,.rich input,.rich select{max-width:100%;min-width:0;box-sizing:border-box}.rich button{white-space:normal;overflow-wrap:anywhere} + +@media print{.rich .source-download{display:none!important}} +.rich .scalar-item{display:flex;align-items:flex-start;gap:.55rem;min-width:0;padding:.3rem 0;border-top:1px solid var(--line)}.rich .scalar-item>.index-label{flex:none;min-width:2.5rem;margin:0;padding-top:.2rem}.rich .scalar-item>.node{min-width:0;flex:1} + +/* Approved responsive transitions; all deep record rails stop at bucket 3. */ +.rich a{color:var(--num)} +.rich .depth-3 .record-card{padding:0;border:0;margin:0;border-radius:0} +.rich .depth-3 .indexed-list{gap:.2rem} +@media(max-width:1200px){.rich,.rich.interactive{padding:1.25rem}.rich .document-header h1{font-size:1.4rem}} +@media(max-width:920px){.rich .layout{grid-template-columns:minmax(0,1fr)}.rich .outline{position:static;max-height:12rem}.rich .document-header{display:block}} +@media(min-width:581px) and (max-width:700px){.rich .field-row{grid-template-columns:minmax(7rem,23%) minmax(0,1fr);gap:.6rem}} +@media(max-width:580px){.rich,.rich.interactive{padding:.8rem}.rich .field-row{grid-template-columns:minmax(0,1fr);gap:.3rem}.rich #jh-query{flex:1 1 100%;width:100%}} """# package static let script = #""" // SPDX-License-Identifier: Apache-2.0 @@ -21,6 +48,32 @@ package enum JSONHTMLInteractiveAssets { const MAX_RESULTS = 100; let searchEpoch = 0; let bulkEpoch = 0; + let activeView = 'structured'; + let hitIDs = [], hitIndex = -1, copyFocus; + const tabs = document.getElementById('jh-tabs'); + const views = ['structured', 'syntax']; + const activate = (view, focus = false) => { + const changed = activeView !== view; + activeView = view; + if (!tabs) return; + for (const name of views) { + const tab = document.getElementById('jh-tab-' + name); + const pane = document.getElementById('jh-' + name); + const selected = name === view; + tab.setAttribute('aria-selected', String(selected)); tab.tabIndex = selected ? 0 : -1; + pane.hidden = !selected; + if (focus && selected) tab.focus(); + } + const label = document.querySelector('label[for="jh-query"]'); + label.textContent = view === 'syntax' ? 'Search exact JSON source' : 'Search previews'; + if (changed) search(); + }; + const moveHit = delta => { + if (!hitIDs.length) return; + hitIndex = hitIndex < 0 ? (delta < 0 ? hitIDs.length - 1 : 0) : (hitIndex + delta + hitIDs.length) % hitIDs.length; + reveal(hitIDs[hitIndex]); + say('Result ' + (hitIndex + 1) + ' of ' + hitIDs.length + ' listed matches (maximum 100).'); + }; const bytes = encoded => { const text = atob(encoded); const result = new Uint8Array(text.length); @@ -31,7 +84,8 @@ package enum JSONHTMLInteractiveAssets { const say = message => { status.textContent = message; }; const reveal = (id, focus = true) => { const node = document.getElementById(id); - if (!node || !tree.contains(node)) return; + if (!node || !(tree.contains(node) || node.id === 'jh-syntax')) return; + if (tabs) activate(node.id === 'jh-syntax' ? 'syntax' : 'structured'); for (let parent = node.parentElement; parent; parent = parent.parentElement) { if (parent.tagName === 'DETAILS') parent.open = true; } @@ -49,21 +103,25 @@ package enum JSONHTMLInteractiveAssets { setTimeout(() => URL.revokeObjectURL(url), 1000); say('Download requested: ' + filename + '.'); }; - const copy = async (encoded, label) => { + const copy = async (encoded, label, opener) => { const value = text(encoded); - manual.hidden = true; + copyFocus = opener || document.activeElement; + if (manual.open) manual.close(); + else manual.hidden = true; try { if (!navigator.clipboard || !navigator.clipboard.writeText) throw new Error('Clipboard unavailable'); await navigator.clipboard.writeText(value); say('Browser accepted ' + label + ' copy. Platform clipboard transformations may differ.'); } catch (_) { manual.hidden = false; manualText.value = value; + if (manual.showModal) manual.showModal(); manualText.focus(); manualText.select(); - say('Clipboard unavailable or rejected. Select the text and copy manually, or download exact UTF-8 bytes.'); + say(value.length === 0 ? 'Root Pointer is empty. Clipboard unavailable or rejected; download exact UTF-8 bytes.' : 'Clipboard unavailable or rejected. Select the text and copy manually, or download exact UTF-8 bytes.'); } }; const bulk = open => { const epoch = ++bulkEpoch; + if (tabs && activeView === 'syntax') { say('The exact JSON source view has no container disclosures.'); return; } const nodes = tree.querySelectorAll('details.node'); let i = 0; const step = () => { @@ -78,7 +136,7 @@ package enum JSONHTMLInteractiveAssets { // Full DOM remains present. Scanning is chunked, not a browser CPU/heap quota. const search = () => { const epoch = ++searchEpoch; - results.replaceChildren(); + results.replaceChildren(); hitIDs = []; hitIndex = -1; const needle = query.value; const byteLimit = Number(query.dataset.byteLimit); let issue = ''; @@ -91,17 +149,19 @@ package enum JSONHTMLInteractiveAssets { if (new TextEncoder().encode(needle).length > byteLimit) issue = 'Search query exceeds the UTF-8 byte limit.'; } const accepted = issue ? '' : needle; - const candidates = tree.querySelectorAll('.node'); + const candidates = activeView === 'syntax' && tabs ? [document.getElementById('jh-syntax')] : tree.querySelectorAll('.node'); let i = 0, matches = 0, listed = 0; const step = () => { if (epoch !== searchEpoch) return; const end = Math.min(i + CHUNK, candidates.length); for (; i < end; i++) { const node = candidates[i]; - const fields = node.tagName === 'DETAILS' ? node.querySelector('summary').querySelectorAll('[data-search]') : node.querySelectorAll(':scope > [data-search]'); + const fields = node.id === 'jh-syntax' ? node.querySelectorAll('[data-source-search]') : node.tagName === 'DETAILS' ? node.querySelector('summary').querySelectorAll('[data-search]') : node.querySelectorAll(':scope > [data-search]'); + const key = node.parentElement.tagName === 'DD' ? node.parentElement.previousElementSibling : null; let matched = false; for (const field of fields) { if (accepted.length > 0 && field.textContent.includes(accepted)) matched = true; } - node.classList.toggle('search-hit', matched); + if (key && accepted.length && key.textContent.includes(accepted)) matched = true; + node.classList.toggle('search-hit', matched && listed < MAX_RESULTS); if (!matched) continue; matches++; if (listed < MAX_RESULTS) { @@ -109,13 +169,14 @@ package enum JSONHTMLInteractiveAssets { const link = document.createElement('a'); link.href = '#' + node.id; link.dataset.target = node.id; - link.textContent = 'Match ' + matches + ': ' + text(node.dataset.pointerEscaped); + link.textContent = 'Match ' + matches + ': ' + (node.dataset.pointerEscaped ? text(node.dataset.pointerEscaped) : 'Exact JSON source'); + hitIDs.push(node.id); item.append(link); results.append(item); listed++; } } if (i < candidates.length) setTimeout(step, 0); else { - say(issue || matches + ' matching nodes in rendered JSON key/value text. ' + listed + ' results listed (maximum ' + MAX_RESULTS + ').'); + say(issue || matches + ' matching nodes in ' + (activeView === 'syntax' ? 'exact JSON source' : 'displayed key/value previews') + '. ' + listed + ' results listed (maximum ' + MAX_RESULTS + ').'); const first = results.querySelector('a'); if (first) reveal(first.dataset.target, false); } @@ -123,6 +184,31 @@ package enum JSONHTMLInteractiveAssets { step(); }; query.addEventListener('input', search); + query.addEventListener('keydown', event => { + if (event.key === 'Escape') { event.preventDefault(); query.value = ''; search(); } + if (event.key === 'Enter') { event.preventDefault(); moveHit(event.shiftKey ? -1 : 1); } + }); + if (tabs) { + tabs.setAttribute('role', 'tablist'); tabs.setAttribute('aria-label', 'JSON view'); + for (const view of views) { + const tab = document.getElementById('jh-tab-' + view), pane = document.getElementById('jh-' + view); + tab.setAttribute('role', 'tab'); tab.setAttribute('aria-controls', pane.id); + pane.setAttribute('role', 'tabpanel'); pane.setAttribute('aria-labelledby', tab.id); + tab.addEventListener('click', event => { event.preventDefault(); activate(view); search(); }); + tab.addEventListener('keydown', event => { + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); + activate(event.key === 'Home' ? 'structured' : event.key === 'End' ? 'syntax' : view === 'syntax' ? 'structured' : 'syntax', true); search(); + }); + } + activate(document.getElementById('jh-structured').dataset.initialView); + } + const theme = document.getElementById('jh-theme'); + if (theme) { + theme.value = document.body.classList.contains('dark') ? 'dark' : document.body.classList.contains('light') ? 'light' : 'automatic'; + theme.addEventListener('change', () => { for (const value of ['automatic', 'light', 'dark']) document.body.classList.toggle(value, value === theme.value); }); + } + if (manual.tagName === 'DIALOG') manual.addEventListener('close', () => { if (copyFocus && copyFocus.isConnected) copyFocus.focus(); }); document.addEventListener('click', event => { const link = event.target.closest('a[data-target]'); if (link) { event.preventDefault(); reveal(link.dataset.target); return; } @@ -130,27 +216,30 @@ package enum JSONHTMLInteractiveAssets { if (!button) return; event.preventDefault(); event.stopPropagation(); const action = button.dataset.action; + if (action === 'next') { moveHit(1); return; } + if (action === 'previous') { moveHit(-1); return; } + if (action === 'close-copy') { manual.close(); return; } if (action === 'expand') { bulk(true); return; } if (action === 'collapse') { bulk(false); return; } if (action === 'clear') { query.value = ''; search(); return; } if (action === 'select') { manualText.focus(); manualText.select(); return; } if (action === 'copy-json' || action === 'download-json') { const encoded = document.getElementById('jh-copy-json').dataset.bytes; - if (action === 'copy-json') void copy(encoded, 'JSON'); + if (action === 'copy-json') void copy(encoded, 'JSON', button); else download(encoded, 'document.json'); return; } const node = document.getElementById(button.dataset.node); if (!node) return; - if (action === 'copy-pointer') void copy(node.dataset.pointer, 'raw RFC 6901 Pointer'); - else if (action === 'copy-pointer-escaped') void copy(node.dataset.pointerEscaped, 'Pointer JSON string'); + if (action === 'copy-pointer') void copy(node.dataset.pointer, 'raw RFC 6901 Pointer', button); + else if (action === 'copy-pointer-escaped') void copy(node.dataset.pointerEscaped, 'Pointer JSON string', button); else if (action === 'download-pointer') download(node.dataset.pointer, 'pointer.txt'); }); document.body.classList.add('enhanced'); document.getElementById('jh-toolbar').hidden = false; - say('Interactive controls ready. Search scope: rendered JSON key/value text.'); + say('Interactive controls ready. Search scope: selected displayed previews or exact JSON source.'); })(); """# - package static let styleHash = "fi44l6KCKEBAprWu6rC4hvdhuRlXkKrLPRfZZl6TXrw=" - package static let scriptHash = "JPrAwRyMk+9Ugeh/SMuDKPXdJRom5+Hq7qc4Xo4GLPs=" + package static let styleHash = "7DSyFzk9+PAtobOu58JQ6klUHFcpTNAffoo1DfByJvA=" + package static let scriptHash = "0E/zm4efMpR/ae2G76+pX+c/zABUnOf3Bmlbu8CFcDc=" } diff --git a/Sources/JSONHTML/JSONHTMLMiniMath.swift b/Sources/JSONHTML/JSONHTMLMiniMath.swift new file mode 100644 index 0000000..c70abe1 --- /dev/null +++ b/Sources/JSONHTML/JSONHTMLMiniMath.swift @@ -0,0 +1,363 @@ +// SPDX-License-Identifier: Apache-2.0 +import JSON + +// Closed grammar rejection is deliberately distinct from operational failure. +package enum JSONHTMLPreviewRejection: Error { case syntax, eligibility } +package struct JSONHTMLMathNode { + package let tag: String + package let text: String + package let children: [Int] +} +package struct JSONHTMLMathTree { + package var nodes: [JSONHTMLMathNode] + package let root: Int + package let reservation: UInt64 + package func emit(output: inout JSONHTMLDocumentBuffer, meter: inout JSONWorkMeter) throws { + guard nodes.indices.contains(root) else { throw JSONHTMLFailure.invalidConfiguration } + for (i, node) in nodes.enumerated() { + try meter.work() + switch node.tag { + case "mi", "mn", "mo": + guard node.children.isEmpty, !node.text.isEmpty else { + throw JSONHTMLFailure.invalidConfiguration + } + case "mrow": + guard !node.children.isEmpty, node.text.isEmpty else { + throw JSONHTMLFailure.invalidConfiguration + } + case "msqrt": + guard node.children.count == 1, node.text.isEmpty else { + throw JSONHTMLFailure.invalidConfiguration + } + case "msup", "msub", "mfrac": + guard node.children.count == 2, node.text.isEmpty else { + throw JSONHTMLFailure.invalidConfiguration + } + case "msubsup": + guard node.children.count == 3, node.text.isEmpty else { + throw JSONHTMLFailure.invalidConfiguration + } + default: throw JSONHTMLFailure.invalidConfiguration + } + for child in node.children { + try meter.work() + guard child >= 0, child < i else { throw JSONHTMLFailure.invalidConfiguration } + } + } + try meter.retain(32) + try output.addDOM(UInt64(nodes.count) * 2 + 2, meter: &meter) + try output.append( + "", meter: &meter) + enum Step { + case node(Int) + case close(String) + } + var stack: [Step] = [] + func push(_ s: Step, _ m: inout JSONWorkMeter) throws { + try m.retain(32) + stack.append(s) + } + try push(.node(root), &meter) + while let step = stack.popLast() { + try meter.release(32) + try meter.work() + switch step { + case .close(let tag): try output.append("", meter: &meter) + case .node(let i): + let n = nodes[i] + try output.append("<" + n.tag + ">", meter: &meter) + if n.children.isEmpty { + try output.escaped(n.text, meter: &meter) + try output.append("", meter: &meter) + } else { + try push(.close(n.tag), &meter) + for child in n.children.reversed() { + try meter.work() + try push(.node(child), &meter) + } + } + } + } + try output.append("", meter: &meter) + try meter.release(32) + } +} +private struct MathFrame { + enum Kind { + case expression, group, fractionNumerator + case fractionDenominator(Int) + case radical + case script(Int, Bool, Int?, Int?) + } + let kind: Kind + var items: [Int] = [] +} +package enum JSONHTMLMiniMath { + // Fixed original mappings, not a general macro registry. + private static let greek: [String: String] = [ + "alpha": "α", "beta": "β", "gamma": "γ", "delta": "δ", "theta": "θ", "lambda": "λ", + "mu": "μ", + "pi": "π", "sigma": "σ", "phi": "φ", "omega": "ω", "Delta": "Δ", "Omega": "Ω", + ] + private static let operators: [String: String] = [ + "times": "×", "cdot": "⋅", "pm": "±", "le": "≤", "ge": "≥", "ne": "≠", + ] + package static func parse( + _ source: String, policy: JSONHTMLPreviewPolicy, meter: inout JSONWorkMeter + ) throws -> JSONHTMLMathTree? { + let entry = meter.used(.retainedBytes) + do { + return try parseBody(source, policy: policy, meter: &meter, entry: entry) + } catch is JSONHTMLPreviewRejection { + try meter.release(meter.used(.retainedBytes) - entry) + return nil + } catch { throw error } + } + private static func parseBody( + _ source: String, policy: JSONHTMLPreviewPolicy, meter: inout JSONWorkMeter, entry: UInt64 + ) throws -> JSONHTMLMathTree { + var scalars: [Unicode.Scalar] = [] + var utf8: UInt64 = 0 + for scalar in source.unicodeScalars { + try meter.work() + utf8 += UInt64(scalar.utf8.count) + guard UInt64(scalars.count) < policy.maxMathSourceScalars, + utf8 <= policy.maxMathSourceUTF8Bytes + else { throw JSONHTMLPreviewRejection.eligibility } + try meter.retain(4) + scalars.append(scalar) + } + // One number-token construction plus fixed command/child-call scratch. + try meter.retain(utf8 + 128) + var nodes: [JSONHTMLMathNode] = [] + var frames: [MathFrame] = [] + var tokenCount: UInt64 = 0 + var i = 0 + func node( + _ tag: String, _ text: String = "", _ children: [Int] = [], _ m: inout JSONWorkMeter + ) + throws -> Int + { + try m.work() + guard UInt64(nodes.count) < policy.maxMathNodes else { + throw JSONHTMLPreviewRejection.eligibility + } + try m.retain(64 + UInt64(children.count) * 8 + UInt64(text.utf8.count)) + nodes.append(.init(tag: tag, text: text, children: children)) + return nodes.count - 1 + } + func push(_ kind: MathFrame.Kind, _ m: inout JSONWorkMeter) throws { + try m.work() + guard UInt64(frames.count) < policy.maxMathSyntaxDepth else { + throw JSONHTMLPreviewRejection.eligibility + } + try m.retain(64) + frames.append(.init(kind: kind)) + } + func append(_ n: Int, _ m: inout JSONWorkMeter) throws { + try m.work() + try m.retain(8) + frames[frames.count - 1].items.append(n) + } + func space(_ m: inout JSONWorkMeter) throws { + while i < scalars.count && scalars[i] == " " { + try m.work() + i += 1 + } + } + func token(_ m: inout JSONWorkMeter) throws { + try m.work() + guard tokenCount < policy.maxMathTokens else { + throw JSONHTMLPreviewRejection.eligibility + } + tokenCount += 1 + } + func command(_ m: inout JSONWorkMeter) throws -> String { + try token(&m) + i += 1 + var text = "" + while i < scalars.count + && ((65...90).contains(scalars[i].value) || (97...122).contains(scalars[i].value)) + { + try m.work() + text.unicodeScalars.append(scalars[i]) + i += 1 + guard text.utf8.count <= 8 else { throw JSONHTMLPreviewRejection.syntax } + } + return text + } + func simple(_ m: inout JSONWorkMeter, single: Bool = false) throws -> Int { + guard i < scalars.count else { throw JSONHTMLPreviewRejection.syntax } + let c = scalars[i] + if c == "\\" { + let name = try command(&m) + guard let text = greek[name] else { throw JSONHTMLPreviewRejection.syntax } + return try node("mi", text, [], &m) + } + if (48...57).contains(c.value) { + try token(&m) + var text = "" + text.unicodeScalars.append(c) + i += 1 + if !single { + while i < scalars.count && (48...57).contains(scalars[i].value) { + try m.work() + text.unicodeScalars.append(scalars[i]) + i += 1 + } + if i < scalars.count && scalars[i] == "." { + text.append(".") + i += 1 + let start = i + while i < scalars.count && (48...57).contains(scalars[i].value) { + try m.work() + text.unicodeScalars.append(scalars[i]) + i += 1 + } + guard i > start else { throw JSONHTMLPreviewRejection.syntax } + } + } + return try node("mn", text, [], &m) + } + if (65...90).contains(c.value) || (97...122).contains(c.value) + || greek.values.contains(String(c)) + { + try token(&m) + i += 1 + return try node("mi", String(c), [], &m) + } + throw JSONHTMLPreviewRejection.syntax + } + func scripted(_ base: Int, _ sub: Int?, _ sup: Int?, _ m: inout JSONWorkMeter) throws -> Int + { + if let sub, let sup { return try node("msubsup", "", [base, sub, sup], &m) } + if let sub { return try node("msub", "", [base, sub], &m) } + if let sup { return try node("msup", "", [base, sup], &m) } + return base + } + try push(.expression, &meter) + var pending: (base: Int, sub: Int?, sup: Int?)? + while true { + try meter.work() + try space(&meter) + if let p = pending { + if i < scalars.count && (scalars[i] == "_" || scalars[i] == "^") { + let isSub = scalars[i] == "_" + try token(&meter) + i += 1 + try space(&meter) + guard isSub ? p.sub == nil : p.sup == nil else { + throw JSONHTMLPreviewRejection.syntax + } + if i < scalars.count && scalars[i] == "{" { + try token(&meter) + i += 1 + pending = nil + try push(.script(p.base, isSub, p.sub, p.sup), &meter) + continue + } + let arg = try simple(&meter, single: true) + // Unbraced alphanumeric runs are ambiguous, never repaired. + if i < scalars.count { + let v = scalars[i].value + if (48...57).contains(v) || (65...90).contains(v) || (97...122).contains(v) + { + throw JSONHTMLPreviewRejection.syntax + } + } + pending = (p.base, isSub ? arg : p.sub, isSub ? p.sup : arg) + continue + } + try append(try scripted(p.base, p.sub, p.sup, &meter), &meter) + pending = nil + continue + } + if i == scalars.count || scalars[i] == "}" { + guard let frame = frames.popLast(), !frame.items.isEmpty else { + throw JSONHTMLPreviewRejection.syntax + } + try meter.release(64 + UInt64(frame.items.count) * 8) + let result = + frame.items.count == 1 + ? frame.items[0] : try node("mrow", "", frame.items, &meter) + switch frame.kind { + case .expression: + guard i == scalars.count, frames.isEmpty else { + throw JSONHTMLPreviewRejection.syntax + } + try meter.release(UInt64(scalars.count) * 4 + utf8 + 128) + return .init( + nodes: nodes, root: result, reservation: meter.used(.retainedBytes) - entry) + case .group: + guard i < scalars.count else { throw JSONHTMLPreviewRejection.syntax } + try token(&meter) + i += 1 + pending = (result, nil, nil) + case .radical: + guard i < scalars.count else { throw JSONHTMLPreviewRejection.syntax } + try token(&meter) + i += 1 + pending = (try node("msqrt", "", [result], &meter), nil, nil) + case .fractionNumerator: + guard i < scalars.count else { throw JSONHTMLPreviewRejection.syntax } + try token(&meter) + i += 1 + try space(&meter) + guard i < scalars.count && scalars[i] == "{" else { + throw JSONHTMLPreviewRejection.syntax + } + try token(&meter) + i += 1 + try push(.fractionDenominator(result), &meter) + case .fractionDenominator(let numerator): + guard i < scalars.count else { throw JSONHTMLPreviewRejection.syntax } + try token(&meter) + i += 1 + pending = (try node("mfrac", "", [numerator, result], &meter), nil, nil) + case .script(let base, let isSub, let sub, let sup): + guard i < scalars.count else { throw JSONHTMLPreviewRejection.syntax } + try token(&meter) + i += 1 + pending = (base, isSub ? result : sub, isSub ? sup : result) + } + continue + } + let c = scalars[i] + if c == "{" { + try token(&meter) + i += 1 + try push(.group, &meter) + continue + } + if c == "\\" { + let name = try command(&meter) + if name == "frac" || name == "sqrt" { + try space(&meter) + guard i < scalars.count && scalars[i] == "{" else { + throw JSONHTMLPreviewRejection.syntax + } + try token(&meter) + i += 1 + try push(name == "frac" ? .fractionNumerator : .radical, &meter) + continue + } + if let g = greek[name] { + pending = (try node("mi", g, [], &meter), nil, nil) + continue + } + if let op = operators[name] { + try append(try node("mo", op, [], &meter), &meter) + continue + } + throw JSONHTMLPreviewRejection.syntax + } + if "+-=(),/×⋅±≤≥≠".unicodeScalars.contains(c) { + try token(&meter) + i += 1 + try append(try node("mo", String(c), [], &meter), &meter) + continue + } + pending = (try simple(&meter), nil, nil) + } + } +} diff --git a/Sources/JSONHTML/JSONHTMLPreparedPolicy.swift b/Sources/JSONHTML/JSONHTMLPreparedPolicy.swift new file mode 100644 index 0000000..12adb24 --- /dev/null +++ b/Sources/JSONHTML/JSONHTMLPreparedPolicy.swift @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +import JSON + +/// Immutable policy accompanying an already-rendered static HTML artifact. +/// Browser fonts/raster readiness is awaited by explicit PDF preparation, not claimed by native admission. +public struct JSONHTMLPreparedPolicy: Sendable { + /// Native structure used by both the static artifact and its print representation. + public let layout: JSONHTMLLayout + /// Main artifact string profile; inspection is explicitly labelled. + public let valueProfile: JSONHTMLValueProfile + /// Print/PDF profile retained by the artifact, with complete values by default. + public let printValueProfile: JSONHTMLValueProfile + /// Fixed native MathML and system fonts; no downloaded fonts. + public let fontPolicy: String + /// PNG/JPEG headers admitted natively; browser decode must complete before PDF capture. + public let mediaPolicy: String + package init(options: JSONExportOptions) { + layout = options.layout + valueProfile = options.valueProfile + printValueProfile = options.printValueProfile + fontPolicy = "System fonts and native MathML Core; await document.fonts.ready" + mediaPolicy = + "Offline canonical PNG/JPEG only; await image decode; native admission is not a raster codec" + } +} diff --git a/Sources/JSONHTML/JSONHTMLPreviewOptions.swift b/Sources/JSONHTML/JSONHTMLPreviewOptions.swift new file mode 100644 index 0000000..3673726 --- /dev/null +++ b/Sources/JSONHTML/JSONHTMLPreviewOptions.swift @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: Apache-2.0 +import JSON + +/// Independent document shape; changing it never changes exact JSON bytes. +public enum JSONHTMLLayout: String, Sendable { + /// Ordered forms, record cards and scalar lists. + case structured + /// Full exact highlighted JSON syntax. + case syntax +} +/// String presentation; both profiles retain full exact source. +public enum JSONHTMLValueProfile: String, Sendable { + /// Labelled bounded prefixes with native original-value disclosure. + case inspection + /// Full expanded original strings, with optional derived previews alongside. + case complete +} +/// The closed inline dialect applied to string previews. +public enum JSONHTMLInlineFormatting: String, Sendable { + /// Literal strings only. + case off + /// Apply only to explicitly or effectively hinted inline text. + case declared + /// Apply to bounded string prefixes after specialized preview resolution. + case automatic +} +/// Whether validated inline link destinations can be activated by a user. +public enum JSONHTMLLinkActivation: String, Sendable { + /// Destinations remain inert text; the default. + case disabled + /// Explicitly allow absolute credential-free HTTP/HTTPS user navigation. + case httpAndHTTPS +} +/// Explicit units for derived color channels, independent of JSON arithmetic. +public enum JSONHTMLColorUnits: String, Sendable { + /// Integral RGB channels from zero through 255; alpha remains zero through one. + case integer8 + /// Exact channels from zero through one; appearance rounds to nearest 8-bit channel. + case normalized +} +/// Explicit channel sequence for an array or named r/g/b/a object. +public enum JSONHTMLColorOrder: String, Sendable { + /// Three r/g/b channels. + case rgb + /// Four r/g/b/a channels. + case rgba +} +/// The only declared raster media types admitted by the native header pipeline. +public enum JSONHTMLRasterMediaType: String, Sendable { + /// A signature/header-admitted PNG; no animation claim. + case png = "image/png" + /// A signature/header-admitted JPEG; decoding remains browser work. + case jpeg = "image/jpeg" +} +/// A closed caller declaration at an exact typed instance location. +public struct JSONHTMLPreviewHint: Sendable { + /// Supported declaration kinds; no callback or arbitrary markup is accepted. + public enum Kind: Sendable { + /// Suppress all inference at this node. + case literal + /// Apply the bounded inline text dialect. + case inlineText + /// Explicit units and channel order; strings still use the complete sRGB grammar. + /// - Parameters: + /// - units: Exact integer8 or normalized interpretation before appearance conversion. + /// - order: Required r/g/b or r/g/b/a field order. + case color(units: JSONHTMLColorUnits, order: JSONHTMLColorOrder) + /// Declare a bare canonical Base64 string with an allowlisted media type. + case embeddedImage(JSONHTMLRasterMediaType) + } + /// Keys and indices retain distinct scalar identity. + public let path: [JSONLocationComponent] + /// Closed declaration; annotation intent is not a schema verdict or codec proof. + public let kind: Kind + /// Creates a declaration without traversing or retaining a source document. + /// - Parameters: + /// - path: Exact typed instance location. + /// - kind: Closed preview declaration. + public init(path: [JSONLocationComponent], kind: Kind) { + self.path = path + self.kind = kind + } +} +/// Finite preview eligibility ceilings, additional to unchanged global core/HTML limits. +/// +/// These initial policies are not raster decoding, browser instruction or RSS caps. +/// Local ineligibility preserves source; actual enclosing exhaustion is terminal. +public struct JSONHTMLPreviewPolicy: Sendable { + /// Decoded source scalars in an inspection prefix. + public var maxSourceScalars: UInt64 = 512 + /// UTF-8 bytes in that prefix. + public var maxPrefixUTF8Bytes: UInt64 = 2048 + /// Reversible display scalars, excluding quotation/status chrome. + public var maxEscapedDisplayScalars: UInt64 = 512 + /// Inline delimiter stack depth. + public var maxInlineNesting: UInt64 = 8 + /// Inline staged token count. + public var maxInlineTokens: UInt64 = 1024 + /// Decoded inline destination UTF-8 bytes. + public var maxDestinationUTF8Bytes: UInt64 = 2048 + /// Inline image alternative text scalars. + public var maxAltScalars: UInt64 = 128 + /// Math candidate source scalars. + public var maxMathSourceScalars: UInt64 = 256 + /// Math candidate UTF-8 bytes. + public var maxMathSourceUTF8Bytes: UInt64 = 1024 + /// Math syntax stack depth. + public var maxMathSyntaxDepth: UInt64 = 16 + /// Math descriptor nodes per candidate. + public var maxMathNodes: UInt64 = 256 + /// Math tokens per candidate. + public var maxMathTokens: UInt64 = 512 + /// Declared encoded raster candidate bytes. + public var maxEncodedImageBytes: UInt64 = 3_145_728 + /// Decoded bytes per image. + public var maxDecodedImageBytes: UInt64 = 2_097_152 + /// Aggregate admitted decoded raster bytes across unique previews. + public var maxAggregateDecodedImageBytes: UInt64 = 8_388_608 + /// Raster width in pixels. + public var maxImageWidth: UInt64 = 2048 + /// Raster height in pixels. + public var maxImageHeight: UInt64 = 2048 + /// Checked raster width times height. + public var maxImagePixels: UInt64 = 4_194_304 + /// Maximum native header bytes examined per candidate. + public var maxImageHeaderBytes: UInt64 = 262_144 + /// Unique admitted raster previews. + public var maxImagePreviews: UInt64 = 64 + /// Caller declaration records per hint tier. + public var maxHintRecords: UInt64 = 1024 + /// Complete color candidate UTF-8 bytes. + public var maxColorCandidateBytes: UInt64 = 256 + /// Creates the initial finite policy; mutate specific ceilings before rendering. + public init() {} + package func validate() throws { + let values = [ + maxSourceScalars, maxPrefixUTF8Bytes, maxEscapedDisplayScalars, maxInlineNesting, + maxInlineTokens, + maxDestinationUTF8Bytes, maxAltScalars, maxMathSourceScalars, maxMathSourceUTF8Bytes, + maxMathSyntaxDepth, + maxMathNodes, maxMathTokens, maxEncodedImageBytes, maxDecodedImageBytes, + maxAggregateDecodedImageBytes, + maxImageWidth, maxImageHeight, maxImagePixels, maxImageHeaderBytes, maxImagePreviews, + maxHintRecords, maxColorCandidateBytes, + ] + guard values.allSatisfy({ $0 < UInt64(Int.max) }) else { + throw JSONHTMLFailure.invalidConfiguration + } + } +} diff --git a/Sources/JSONHTML/JSONHTMLPreviewPlan.swift b/Sources/JSONHTML/JSONHTMLPreviewPlan.swift new file mode 100644 index 0000000..44420fa --- /dev/null +++ b/Sources/JSONHTML/JSONHTMLPreviewPlan.swift @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: Apache-2.0 +import JSON + +package enum JSONHTMLPrimaryPreview { + case literal + case inline(JSONHTMLInlinePlan) + case color(JSONHTMLColor) + case image(JSONHTMLRaster) +} +package struct JSONHTMLNodePreview { + let prefix: JSONHTMLStringPrefix? + let primary: JSONHTMLPrimaryPreview +} +package struct JSONHTMLPreviewIssue { + let path: [JSONLocationComponent] + let message: String +} +package struct JSONHTMLPreviewPlan { + var nodes: [JSONHTMLNodePreview] + var issues: [JSONHTMLPreviewIssue] + let media: JSONHTMLMediaLedger + let reservation: UInt64 + var shortened: Bool + package static func make( + _ value: JSON, options: JSONHTMLRenderingOptions, meter: inout JSONWorkMeter + ) throws -> Self { + let entry = meter.used(.retainedBytes) + try options.previewPolicy.validate() + let explicit = options.previewHints + let effective = options.effectivePreviewHints + for tier in [explicit, effective] { + try meter.work() + guard UInt64(tier.count) <= options.previewPolicy.maxHintRecords else { + throw JSONHTMLFailure.invalidConfiguration + } + for i in tier.indices { + try meter.work() + try meter.require(.pointerTokens, UInt64(tier[i].path.count)) + for j in 0.. Bool { + let v = c.value + return c.properties.generalCategory == .format || v <= 31 || (127...159).contains(v) + || [0xad, 0x61c, 0xfeff, 0xe0001].contains(v) + || (0x200b...0x200f).contains(v) || (0x2028...0x202e).contains(v) + || (0x2060...0x206f).contains(v) || (0xfff9...0xfffb).contains(v) + || (0xe0020...0xe007f).contains(v) + } + package static func units(_ c: Unicode.Scalar) -> UInt64 { + if protected(c) { return c.value > 0xffff ? 12 : 6 } + return c == "\"" || c == "\\" ? 2 : 1 + } + package static func prefix( + _ text: String, policy: JSONHTMLPreviewPolicy, meter: inout JSONWorkMeter + ) throws -> JSONHTMLStringPrefix { + var iterator = text.unicodeScalars.makeIterator() + var source = "" + var count: UInt64 = 0 + var bytes: UInt64 = 0 + var display: UInt64 = 0 + while let c = iterator.next() { + try meter.work() + let width = UInt64(c.utf8.count) + let cost = units(c) + if count >= policy.maxSourceScalars || bytes + width > policy.maxPrefixUTF8Bytes + || display + cost > policy.maxEscapedDisplayScalars + { + return .init( + source: source, shortened: true, scalars: count, displayScalars: display, + reservation: bytes) + } + for _ in c.utf8 { + try meter.work() + try meter.retain(1) + } + source.unicodeScalars.append(c) + count += 1 + bytes += width + display += cost + } + return .init( + source: source, shortened: false, scalars: count, displayScalars: display, + reservation: bytes) + } + package static func text( + _ text: String, quoted: Bool, output: inout JSONHTMLDocumentBuffer, + meter: inout JSONWorkMeter + ) throws { + if quoted { try output.escaped(34, meter: &meter) } + func escape(_ value: UInt32, _ out: inout JSONHTMLDocumentBuffer, _ m: inout JSONWorkMeter) + throws + { + try out.append("\\u", meter: &m) + for shift in [12, 8, 4, 0] { + try m.work() + try out.appendByte(hex[Int(value >> shift & 15)], meter: &m) + } + } + for c in text.unicodeScalars { + try meter.work() + if protected(c) { + if c.value <= 0xffff { + try escape(c.value, &output, &meter) + } else { + let v = c.value - 0x10000 + try escape(0xd800 + (v >> 10), &output, &meter) + try escape(0xdc00 + (v & 1023), &output, &meter) + } + } else if c == "\"" || c == "\\" { + try output.appendByte(92, meter: &meter) + for b in c.utf8 { try output.escaped(b, meter: &meter) } + } else { + for b in c.utf8 { try output.escaped(b, meter: &meter) } + } + } + if quoted { try output.escaped(34, meter: &meter) } + } + package static func equal( + _ lhs: [JSONLocationComponent], _ rhs: [JSONLocationComponent], meter: inout JSONWorkMeter + ) throws -> Bool { + try meter.work() + guard lhs.count == rhs.count else { return false } + for (a, b) in zip(lhs, rhs) { + try meter.work() + switch (a, b) { + case (.index(let x), .index(let y)): if x != y { return false } + case (.key(let x), .key(let y)): + var l = x.string.utf8.makeIterator() + var r = y.string.utf8.makeIterator() + while true { + try meter.work() + let a = l.next() + let b = r.next() + if a != b { return false } + if a == nil { break } + } + default: return false + } + } + return true + } +} +package struct JSONHTMLColor { + package let r: UInt8, g: UInt8, b: UInt8, a: Double + package let derived: Bool + package func emit( + ordinal: UInt64, namespace: String, label: JSONHTMLStringPrefix?, + output: inout JSONHTMLDocumentBuffer, meter: inout JSONWorkMeter + ) throws { + guard a.isFinite, a >= 0, a <= 1 else { throw JSONHTMLFailure.invalidConfiguration } + try meter.retain(64) + try output.addDOM(18, meter: &meter) + try output.append( + "", + meter: &meter) + if !derived, let label { + try output.escaped(label.source, meter: &meter) + if label.shortened { try output.append(" (preview)", meter: &meter) } + } else { + try output.append("Derived #", meter: &meter) + for v in [r, g, b, UInt8((a * 255).rounded())] { + for n in [v >> 4, v & 15] { + try meter.work() + try output.appendByte(n < 10 ? n + 48 : n + 87, meter: &meter) + } + } + } + try output.append("", meter: &meter) + try output.append(derived ? "Derived sRGB channels: " : "sRGB channels: ", meter: &meter) + for (i, v) in [r, g, b].enumerated() { + if i > 0 { try output.append(", ", meter: &meter) } + try output.decimal(UInt64(v), meter: &meter) + } + try output.append("; alpha ", meter: &meter) + try output.append(String(a), meter: &meter) + try output.append("", meter: &meter) + try meter.release(64) + } + package static func parse( + _ source: String, policy: JSONHTMLPreviewPolicy, meter: inout JSONWorkMeter + ) throws -> Self? { + var bytes: [UInt8] = [] + for b in source.utf8 { + try meter.work() + guard UInt64(bytes.count) < policy.maxColorCandidateBytes, b >= 32, b < 127 else { + try meter.release(UInt64(bytes.count)) + return nil + } + try meter.retain(1) + bytes.append(b) + } + try meter.retain(128) + let result = try parseBytes(bytes, meter: &meter) + try meter.release(128 + UInt64(bytes.count)) + return result + } + private static func parseBytes(_ bytes: [UInt8], meter: inout JSONWorkMeter) throws -> Self? { + func nibble(_ x: UInt8) -> UInt8? { + switch x { + case 48...57: return x - 48 + case 65...70: return x - 55 + case 97...102: return x - 87 + default: return nil + } + } + if bytes.first == 35 { + guard [4, 5, 7, 9].contains(bytes.count) else { return nil } + var values: [UInt8] = [] + let short = bytes.count < 6 + var i = 1 + while i < bytes.count { + try meter.work() + guard let n = nibble(bytes[i]) else { return nil } + if short { + values.append(n * 17) + i += 1 + } else { + guard let m = nibble(bytes[i + 1]) else { return nil } + values.append(n * 16 + m) + i += 2 + } + } + return .init( + r: values[0], g: values[1], b: values[2], + a: values.count == 4 ? Double(values[3]) / 255 : 1, derived: false) + } + let rgba = bytes.starts(with: Array("rgba(".utf8)) + let rgb = bytes.starts(with: Array("rgb(".utf8)) + guard rgba || rgb else { return nil } + var i = rgba ? 5 : 4 + var values: [Double] = [] + for channel in 0..<(rgba ? 4 : 3) { + while i < bytes.count && bytes[i] == 32 { + try meter.work() + i += 1 + } + var value: UInt64 = 0 + var digits = 0 + while i < bytes.count && (48...57).contains(bytes[i]) { + try meter.work() + guard value <= 255 else { return nil } + value = value * 10 + UInt64(bytes[i] - 48) + digits += 1 + i += 1 + } + guard digits > 0 || (channel == 3 && i < bytes.count && bytes[i] == 46) else { + return nil + } + var projected = Double(value) + if channel == 3 && i < bytes.count && bytes[i] == 46 { + i += 1 + let start = i + var factor = 0.1 + while i < bytes.count && (48...57).contains(bytes[i]) { + try meter.work() + if value == 1 && bytes[i] != 48 { return nil } + projected += Double(bytes[i] - 48) * factor + factor /= 10 + i += 1 + } + guard i > start else { return nil } + } + guard projected <= Double(channel == 3 ? 1 : 255) else { return nil } + values.append(projected) + while i < bytes.count && bytes[i] == 32 { + try meter.work() + i += 1 + } + let separator: UInt8 = channel == (rgba ? 3 : 2) ? 41 : 44 + guard i < bytes.count && bytes[i] == separator else { return nil } + i += 1 + } + guard i == bytes.count else { return nil } + return .init( + r: UInt8(values[0]), g: UInt8(values[1]), b: UInt8(values[2]), a: rgba ? values[3] : 1, + derived: true) + } + package static func hinted( + _ value: JSON, units: JSONHTMLColorUnits, order: JSONHTMLColorOrder, + meter: inout JSONWorkMeter + ) throws -> Self? { + try meter.retain(512) + let result = try hintedBody(value, units: units, order: order, meter: &meter) + try meter.release(512) + return result + } + private static func hintedBody( + _ value: JSON, units: JSONHTMLColorUnits, order: JSONHTMLColorOrder, + meter: inout JSONWorkMeter + ) throws -> Self? { + var channels: [JSON] = [] + switch value { + case .array(let v): + guard v.count <= 4 else { return nil } + channels = v + case .object(let o): + for key in order == .rgb ? ["r", "g", "b"] : ["r", "g", "b", "a"] { + try meter.work() + guard let v = try o.lookup(key, meter: &meter) else { return nil } + channels.append(v) + } + guard o.count == channels.count else { return nil } + default: return nil + } + guard channels.count == (order == .rgb ? 3 : 4) else { return nil } + var projected: [Double] = [] + for (i, c) in channels.enumerated() { + try meter.work() + guard case .number(let n) = c else { return nil } + if units == .integer8 && i < 3 { + guard n.components.exponent >= 0 else { return nil } + // Range/type rejection is local; a real numeric meter failure must propagate. + guard try n.compare(to: .init(integer: 0), meter: &meter) != .orderedAscending, + try n.compare(to: .init(integer: 255), meter: &meter) != .orderedDescending + else { return nil } + projected.append(Double(try n.integer(as: UInt8.self, meter: &meter))) + } else { + guard try n.compare(to: .init(integer: 0), meter: &meter) != .orderedAscending, + try n.compare(to: .init(integer: 1), meter: &meter) != .orderedDescending + else { return nil } + projected.append(try n.doubleValue(policy: .rounded, meter: &meter)) + } + } + func byte(_ i: Int) -> UInt8 { + UInt8(units == .normalized ? (projected[i] * 255).rounded() : projected[i]) + } + return .init( + r: byte(0), g: byte(1), b: byte(2), a: projected.count == 4 ? projected[3] : 1, + derived: true) + } +} diff --git a/Sources/JSONHTML/JSONHTMLRaster.swift b/Sources/JSONHTML/JSONHTMLRaster.swift new file mode 100644 index 0000000..8e1ad15 --- /dev/null +++ b/Sources/JSONHTML/JSONHTMLRaster.swift @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: Apache-2.0 +import JSON + +package struct JSONHTMLRaster { + package let type: JSONHTMLRasterMediaType + package let bytes: [UInt8] + package let width: UInt32, height: UInt32 + package let progressive: Bool + package var reservation: UInt64 { UInt64(bytes.count) } + package func emit( + alt: String? = nil, output: inout JSONHTMLDocumentBuffer, meter: inout JSONWorkMeter + ) throws { + try output.addDOM(6, meter: &meter) + try output.append("
    \"",
    ", meter: &meter) + try output.append(type.rawValue, meter: &meter) + try output.append(" · ", meter: &meter) + try output.decimal(UInt64(width), meter: &meter) + try output.append(" × ", meter: &meter) + try output.decimal(UInt64(height), meter: &meter) + try output.append( + " pixels · native header admitted; raster decoding is browser work
    ", + meter: &meter) + } +} +package struct JSONHTMLMediaLedger { + package var bytes: UInt64 = 0 + package var images: UInt64 = 0 +} +package enum JSONHTMLRasterAdmission { + private static let pngDepths: [UInt8: [UInt8]] = [ + 0: [1, 2, 4, 8, 16], 2: [8, 16], 3: [1, 2, 4, 8], 4: [8, 16], 6: [8, 16], + ] + package static func decode( + _ source: String, type: JSONHTMLRasterMediaType?, policy: JSONHTMLPreviewPolicy, + ledger: inout JSONHTMLMediaLedger, meter: inout JSONWorkMeter + ) throws -> JSONHTMLRaster? { + let retained = meter.used(.retainedBytes) + do { + var iterator = source.utf8.makeIterator() + var declared = type + var encoded: [UInt8] = [] + if declared == nil { + var header: [UInt8] = [] + while let b = iterator.next() { + try meter.work() + guard header.count < 32 else { throw JSONHTMLPreviewRejection.syntax } + try meter.retain(1) + header.append(b) + if b == 44 { break } + } + if header == Array("data:image/png;base64,".utf8) { + declared = .png + } else if header == Array("data:image/jpeg;base64,".utf8) { + declared = .jpeg + } else { + throw JSONHTMLPreviewRejection.syntax + } + try meter.release(UInt64(header.count)) + } + while let b = iterator.next() { + try meter.work() + guard UInt64(encoded.count) < policy.maxEncodedImageBytes else { + throw JSONHTMLPreviewRejection.eligibility + } + try meter.retain(1) + encoded.append(b) + } + guard !encoded.isEmpty, encoded.count % 4 == 0, ledger.images < policy.maxImagePreviews + else { + throw JSONHTMLPreviewRejection.eligibility + } + let padding = encoded.last == 61 ? (encoded[encoded.count - 2] == 61 ? 2 : 1) : 0 + let size = UInt64(encoded.count / 4) * 3 - UInt64(padding) + guard size <= policy.maxDecodedImageBytes, + size <= policy.maxAggregateDecodedImageBytes - ledger.bytes + else { throw JSONHTMLPreviewRejection.eligibility } + let decoded = try canonical(encoded, maximum: size, meter: &meter) + try meter.release(UInt64(encoded.count)) + let geometry = try dimensions(decoded, type: declared!, policy: policy, meter: &meter) + let pixels = UInt64(geometry.0) * UInt64(geometry.1) + guard geometry.0 > 0, geometry.1 > 0, UInt64(geometry.0) <= policy.maxImageWidth, + UInt64(geometry.1) <= policy.maxImageHeight, pixels <= policy.maxImagePixels + else { throw JSONHTMLPreviewRejection.eligibility } + ledger.bytes += size + ledger.images += 1 + return .init( + type: declared!, bytes: decoded, width: geometry.0, height: geometry.1, + progressive: geometry.2) + } catch is JSONHTMLPreviewRejection { + try meter.release(meter.used(.retainedBytes) - retained) + return nil + } catch { throw error } + } + package static func canonical(_ encoded: [UInt8], maximum: UInt64, meter: inout JSONWorkMeter) + throws -> [UInt8] + { + guard encoded.count % 4 == 0 else { throw JSONHTMLPreviewRejection.syntax } + func digit(_ b: UInt8) -> UInt32? { + switch b { + case 65...90: return UInt32(b - 65) + case 97...122: return UInt32(b - 71) + case 48...57: return UInt32(b + 4) + case 43: return 62 + case 47: return 63 + default: return nil + } + } + var bytes: [UInt8] = [] + var i = 0 + while i < encoded.count { + try meter.work(4) + guard let a = digit(encoded[i]), let b = digit(encoded[i + 1]) else { + throw JSONHTMLPreviewRejection.syntax + } + let c = encoded[i + 2] + let d = encoded[i + 3] + let last = i + 4 == encoded.count + var value = a << 18 | b << 12 + let emitted: Int + if c == 61 { + guard last, d == 61, b & 15 == 0 else { throw JSONHTMLPreviewRejection.syntax } + emitted = 1 + } else { + guard let cc = digit(c) else { throw JSONHTMLPreviewRejection.syntax } + value |= cc << 6 + if d == 61 { + guard last, cc & 3 == 0 else { throw JSONHTMLPreviewRejection.syntax } + emitted = 2 + } else { + guard let dd = digit(d) else { throw JSONHTMLPreviewRejection.syntax } + value |= dd + emitted = 3 + } + } + for n in 0..> UInt32(16 - 8 * n) & 255)) + } + i += 4 + } + return bytes + } + private static func dimensions( + _ data: [UInt8], type: JSONHTMLRasterMediaType, policy: JSONHTMLPreviewPolicy, + meter: inout JSONWorkMeter + ) throws -> (UInt32, UInt32, Bool) { + var scanned: UInt64 = 0 + func read(_ i: Int, _ m: inout JSONWorkMeter) throws -> UInt8 { + try m.work() + guard i >= 0, i < data.count, scanned < policy.maxImageHeaderBytes else { + throw JSONHTMLPreviewRejection.eligibility + } + scanned += 1 + return data[i] + } + func u32(_ i: Int, _ m: inout JSONWorkMeter) throws -> UInt32 { + var n: UInt32 = 0 + for x in i..<(i + 4) { n = n << 8 | UInt32(try read(x, &m)) } + return n + } + if type == .png { + guard data.count >= 45 else { throw JSONHTMLPreviewRejection.syntax } + for (i, b) in [UInt8(137), 80, 78, 71, 13, 10, 26, 10].enumerated() { + guard try read(i, &meter) == b else { throw JSONHTMLPreviewRejection.syntax } + } + guard try u32(8, &meter) == 13 else { throw JSONHTMLPreviewRejection.syntax } + for (i, b) in Array("IHDR".utf8).enumerated() { + guard try read(i + 12, &meter) == b else { throw JSONHTMLPreviewRejection.syntax } + } + let width = try u32(16, &meter) + let height = try u32(20, &meter) + let depth = try read(24, &meter) + let color = try read(25, &meter) + guard pngDepths[color]?.contains(depth) == true, try read(26, &meter) == 0, + try read(27, &meter) == 0, try read(28, &meter) <= 1 + else { throw JSONHTMLPreviewRejection.syntax } + // Owned IHDR CRC check; compressed raster validation is deliberately separate. + var crc: UInt32 = 0xffff_ffff + for i in 12..<29 { + crc ^= UInt32(try read(i, &meter)) + for _ in 0..<8 { + try meter.work() + crc = crc & 1 == 1 ? (crc >> 1) ^ 0xedb8_8320 : crc >> 1 + } + } + guard crc ^ 0xffff_ffff == (try u32(29, &meter)) else { + throw JSONHTMLPreviewRejection.syntax + } + var at = 33 + var seenIDAT = false + var ended = false + while at < data.count { + guard data.count - at >= 12 else { throw JSONHTMLPreviewRejection.syntax } + let length = UInt64(try u32(at, &meter)) + guard length <= UInt64(data.count - at) - 12 else { + throw JSONHTMLPreviewRejection.syntax + } + let tag = try u32(at + 4, &meter) + if tag == 0x6163_544c { throw JSONHTMLPreviewRejection.syntax } // acTL: no animation claim. + if tag == 0x4948_4452 { throw JSONHTMLPreviewRejection.syntax } + if tag == 0x4944_4154 { seenIDAT = true } + if tag == 0x4945_4e44 { + guard length == 0, seenIDAT, at + 12 == data.count else { + throw JSONHTMLPreviewRejection.syntax + } + ended = true + break + } + at += Int(length) + 12 + } + guard ended else { throw JSONHTMLPreviewRejection.syntax } + return (width, height, false) + } + guard data.count >= 4, try read(0, &meter) == 255, try read(1, &meter) == 216, + data[data.count - 2] == 255, data.last == 217 + else { throw JSONHTMLPreviewRejection.syntax } + var at = 2 + while at < data.count { + guard try read(at, &meter) == 255 else { throw JSONHTMLPreviewRejection.syntax } + at += 1 + while try at < data.count && read(at, &meter) == 255 { at += 1 } + let marker = try read(at, &meter) + at += 1 + guard marker != 0, marker != 0xd9, marker != 0xda else { + throw JSONHTMLPreviewRejection.syntax + } + if marker == 0x01 || (0xd0...0xd7).contains(marker) { continue } + let length = Int(try read(at, &meter)) * 256 + Int(try read(at + 1, &meter)) + guard length >= 2, length <= data.count - at else { + throw JSONHTMLPreviewRejection.syntax + } + if marker == 0xc0 || marker == 0xc2 { + guard length >= 8, try read(at + 2, &meter) == 8 else { + throw JSONHTMLPreviewRejection.syntax + } + let height = + UInt32(try read(at + 3, &meter)) * 256 + UInt32(try read(at + 4, &meter)) + let width = + UInt32(try read(at + 5, &meter)) * 256 + UInt32(try read(at + 6, &meter)) + let components = Int(try read(at + 7, &meter)) + guard [1, 3, 4].contains(components), length == 8 + 3 * components else { + throw JSONHTMLPreviewRejection.syntax + } + for n in 0..> 4), (1...4).contains(sampling & 15), + try read(at + 10 + n * 3, &meter) <= 3 + else { throw JSONHTMLPreviewRejection.syntax } + } + return (width, height, marker == 0xc2) + } + // Count skipped header bytes as scan work before advancing a bounded segment. + guard UInt64(length) <= policy.maxImageHeaderBytes - scanned else { + throw JSONHTMLPreviewRejection.eligibility + } + try meter.work(UInt64(length)) + scanned += UInt64(length) + at += length + } + throw JSONHTMLPreviewRejection.syntax + } +} diff --git a/Sources/JSONHTML/JSONHTMLRenderReport.swift b/Sources/JSONHTML/JSONHTMLRenderReport.swift index 04de733..c614dad 100644 --- a/Sources/JSONHTML/JSONHTMLRenderReport.swift +++ b/Sources/JSONHTML/JSONHTMLRenderReport.swift @@ -15,14 +15,23 @@ public struct JSONHTMLStatistics: Sendable, Equatable { public let outlineEntries: UInt64 /// Emitted Base64 metadata bytes; distinct from complete document and raw copy bytes. public let metadataBytes: UInt64 - /// Raw exact JSON bytes embedded for interactive copy/download; zero in formatted mode. + /// Raw exact JSON bytes embedded for exact download/copy; zero in legacy static syntax. public let copyJSONBytes: UInt64 /// Accepted caller-supplied diagnostics; zero does not assert schema validity. public let annotations: UInt64 - package init(operation: JSONOperationStatistics, nodes: UInt64, dom: UInt64, bytes: UInt64, outline: UInt64, metadata: UInt64, copyBytes: UInt64, annotations: UInt64) { + /// Whether at least one inspection prefix is shorter than its original source. + public let hasShortenedStrings: Bool + /// Bounded preview diagnostics; these are not schema-validation verdicts. + public let previewIssues: UInt64 + /// Unique header-admitted raster previews, counted once across source/print copies. + public let imagePreviews: UInt64 + /// Logical admitted raster bytes, not browser decoding or process RSS. + public let decodedImageBytes: UInt64 + package init(operation: JSONOperationStatistics, nodes: UInt64, dom: UInt64, bytes: UInt64, outline: UInt64, metadata: UInt64, copyBytes: UInt64, annotations: UInt64, shortened: Bool = false, previewIssues: UInt64 = 0, imagePreviews: UInt64 = 0, imageBytes: UInt64 = 0) { self.operation = operation; renderedJSONNodes = nodes domNodeUpperBound = dom; documentBytes = bytes outlineEntries = outline; metadataBytes = metadata; copyJSONBytes = copyBytes; self.annotations = annotations + hasShortenedStrings = shortened; self.previewIssues = previewIssues; self.imagePreviews = imagePreviews; decodedImageBytes = imageBytes } } diff --git a/Sources/JSONHTML/JSONHTMLRenderer.swift b/Sources/JSONHTML/JSONHTMLRenderer.swift index cf411d1..2766754 100644 --- a/Sources/JSONHTML/JSONHTMLRenderer.swift +++ b/Sources/JSONHTML/JSONHTMLRenderer.swift @@ -119,7 +119,7 @@ public struct JSONHTMLRenderer: Sendable { } private func renderBody(_ value: JSON, admitted: Bool = false, document: JSONDocument? = nil, meter: inout JSONWorkMeter) throws -> JSONHTMLDocumentBuffer { try meter.checkpoint() - try options.limits.validate() + try options.limits.validate(); try options.previewPolicy.validate() guard (0...8).contains(options.indentation), (1...8).contains(options.tabWidth), options.initiallyExpandedDepth >= 0, options.outlineDepth.map({ $0 >= 0 }) ?? true else { throw JSONHTMLFailure.invalidConfiguration @@ -133,8 +133,8 @@ public struct JSONHTMLRenderer: Sendable { if !admitted { try JSONAdmission.value(value, meter: &meter) } try JSONHTMLDocumentBuffer.require(.renderedJSONNodes, meter.used(.nodes), options.limits.maxRenderedJSONNodes) let annotations = try bindAnnotations(value, document: document, meter: &meter) - if options.presentation == .interactive { - let result = try renderInteractive(value, annotations: annotations, meter: &meter) + if options.presentation == .interactive || options.layout == .structured { + let result = try renderRichDocument(value, annotations: annotations, meter: &meter) try meter.release(UInt64(annotations.count) * 64) return result } @@ -204,6 +204,10 @@ package struct JSONHTMLDocumentBuffer { private var metadata: UInt64 = 0 private var annotations: UInt64 = 0 private var copyBytes: UInt64 = 0 + private var shortened = false + private var previewIssues: UInt64 = 0 + private var imagePreviews: UInt64 = 0 + private var imageBytes: UInt64 = 0 private let limits: JSONHTMLLimits package init(limits: JSONHTMLLimits) { self.limits = limits } package static func require(_ metric: JSONHTMLResourceMetric, _ count: UInt64, _ limit: UInt64) throws { @@ -223,6 +227,9 @@ package struct JSONHTMLDocumentBuffer { try meter.work() nodes = try Self.adding(.renderedJSONNodes, nodes, 1, limits.maxRenderedJSONNodes) } + package mutating func setPreviews(shortened: Bool, issues: UInt64, images: UInt64, imageBytes: UInt64) { + self.shortened = shortened; previewIssues = issues; imagePreviews = images; self.imageBytes = imageBytes + } package mutating func setAnnotations(_ count: UInt64) { annotations = count } package mutating func setCopyBytes(_ count: UInt64) { copyBytes = count } package mutating func addOutline(meter: inout JSONWorkMeter) throws { @@ -256,6 +263,6 @@ package struct JSONHTMLDocumentBuffer { } } package func statistics(_ operation: JSONOperationStatistics) -> JSONHTMLStatistics { - .init(operation: operation, nodes: nodes, dom: dom, bytes: UInt64(bytes.count), outline: outline, metadata: metadata, copyBytes: copyBytes, annotations: annotations) + .init(operation: operation, nodes: nodes, dom: dom, bytes: UInt64(bytes.count), outline: outline, metadata: metadata, copyBytes: copyBytes, annotations: annotations, shortened: shortened, previewIssues: previewIssues, imagePreviews: imagePreviews, imageBytes: imageBytes) } } diff --git a/Sources/JSONHTML/JSONHTMLRenderingOptions.swift b/Sources/JSONHTML/JSONHTMLRenderingOptions.swift index 267b8af..6e61a61 100644 --- a/Sources/JSONHTML/JSONHTMLRenderingOptions.swift +++ b/Sources/JSONHTML/JSONHTMLRenderingOptions.swift @@ -13,9 +13,9 @@ public enum JSONHTMLTheme: String, Sendable { /// Presentation of the complete standalone JSON document. public enum JSONHTMLPresentation: String, Sendable { - /// Expanded highlighted JSON without scripting. + /// Script-free expanded values in the independently selected layout. case formatted - /// Native disclosures, optional outline and fixed offline enhancement script. + /// Native structured disclosures and exact JSON pane with optional offline enhancement. case interactive } @@ -92,7 +92,7 @@ public struct JSONHTMLLimits: Sendable { /// Configuration for complete highlighted HTML with optional offline interaction. public struct JSONHTMLRenderingOptions: Sendable { - /// Plain document title; control characters are rejected and HTML is escaped. + /// Plain document title; ASCII control characters are rejected and HTML is escaped. public var title: String /// Automatic, light or dark color policy. public var theme: JSONHTMLTheme @@ -108,8 +108,28 @@ public struct JSONHTMLRenderingOptions: Sendable { /// Caller-supplied typed diagnostics; rendering does not execute schema validation. public var annotations: [JSONHTMLAnnotation] = [] - /// Formatted by default; interactive behavior is explicitly selected. - public var presentation: JSONHTMLPresentation = .formatted + /// Interactive by default; formatted retains the script-free static spelling. + public var presentation: JSONHTMLPresentation = .interactive + /// Structured by default; syntax is explicitly selectable independently of presentation. + public var layout: JSONHTMLLayout = .structured + /// Direct structured rendering defaults to labelled inspection prefixes. + public var valueProfile: JSONHTMLValueProfile = .inspection + /// Complete print is independent of the active screen pane and disclosure state. + public var printValueProfile: JSONHTMLValueProfile = .complete + /// Closed inline dialect; syntax and exact copy bytes never run it. + public var inlineFormatting: JSONHTMLInlineFormatting = .automatic + /// Recognize complete supported sRGB strings and explicitly hinted channels. + public var colorPreviews: Bool = true + /// Admit only declared PNG/JPEG previews through the native bounded pipeline. + public var embeddedImagePreviews: Bool = true + /// User navigation is disabled unless explicitly enabled for checked HTTP/HTTPS links. + public var linkActivation: JSONHTMLLinkActivation = .disabled + /// Explicit typed declarations; literal suppression takes precedence. + public var previewHints: [JSONHTMLPreviewHint] = [] + /// Caller-translated effective instance annotations; lower priority than explicit declarations. + public var effectivePreviewHints: [JSONHTMLPreviewHint] = [] + /// Additional checked finite preview eligibility policy. + public var previewPolicy: JSONHTMLPreviewPolicy = .init() /// Containers at levels below this depth start open; root is level zero. public var initiallyExpandedDepth: Int = 2 /// Include containers through this level in the outline; nil disables the outline. @@ -126,7 +146,7 @@ public struct JSONHTMLRenderingOptions: Sendable { /// - indentationUnit: Space units by default, or literal tabs. /// - tabWidth: Visual tab width, one through eight. /// - limits: Inclusive core and HTML capacities and work quotas. - public init(title: String = "JSON", theme: JSONHTMLTheme = .automatic, + public init(title: String = "JSON document", theme: JSONHTMLTheme = .automatic, indentation: Int = 2, indentationUnit: JSONIndentationUnit = .spaces, tabWidth: Int = 4, limits: JSONHTMLLimits = .init()) { self.title = title; self.theme = theme; self.indentation = indentation @@ -146,7 +166,7 @@ public struct JSONHTMLRenderingOptions: Sendable { /// - presentation: Formatted or interactive standalone document. /// - initiallyExpandedDepth: Nonnegative initial disclosure depth; root is level zero. /// - outlineDepth: Nonnegative inclusive container level, or nil to omit the outline. - public init(title: String = "JSON", theme: JSONHTMLTheme = .automatic, + public init(title: String = "JSON document", theme: JSONHTMLTheme = .automatic, indentation: Int = 2, indentationUnit: JSONIndentationUnit = .spaces, tabWidth: Int = 4, limits: JSONHTMLLimits = .init(), presentation: JSONHTMLPresentation, initiallyExpandedDepth: Int = 2, @@ -156,6 +176,22 @@ public struct JSONHTMLRenderingOptions: Sendable { self.presentation = presentation; self.initiallyExpandedDepth = initiallyExpandedDepth self.outlineDepth = outlineDepth } + /// Creates an explicitly selected layout with independent presentation/value policies. + /// - Parameters: + /// - layout: Structured or complete syntax layout. + /// - presentation: Interactive by default; formatted means script-free static output. + /// - valueProfile: Inspection by default for direct structured rendering. + /// - printValueProfile: Complete by default, independent of screen previews. + /// - title: Escaped plain title; ASCII controls are rejected. + /// - theme: Automatic/light/dark color policy. + /// - limits: Unchanged core and HTML ceilings. + public init(layout: JSONHTMLLayout, presentation: JSONHTMLPresentation = .interactive, + valueProfile: JSONHTMLValueProfile = .inspection, printValueProfile: JSONHTMLValueProfile = .complete, + title: String = "JSON document", theme: JSONHTMLTheme = .automatic, limits: JSONHTMLLimits = .init()) { + self.init(title: title, theme: theme, limits: limits, presentation: presentation) + self.layout = layout; self.valueProfile = valueProfile; self.printValueProfile = printValueProfile + } + } /// Stable identifiers for additional HTML capacities. diff --git a/Sources/JSONHTML/JSONHTMLStructured.swift b/Sources/JSONHTML/JSONHTMLStructured.swift new file mode 100644 index 0000000..b75a20f --- /dev/null +++ b/Sources/JSONHTML/JSONHTMLStructured.swift @@ -0,0 +1,507 @@ +// SPDX-License-Identifier: Apache-2.0 +import JSON + +private enum StructuredStep { + case node(JSON, JSONLocationComponent?, Int) + case end(String, Bool) + case objectMember(JSON, JSONKey, Int) + case arrayMember(JSON, Int, Int, Bool, Bool) +} +private struct StructuredOutline { + let ordinal: UInt64 + let component: JSONLocationComponent? + let depth: Int +} + +extension JSONHTMLRenderer { + package func renderRichDocument( + _ value: JSON, annotations: [JSONHTMLBoundAnnotation], meter: inout JSONWorkMeter + ) throws -> JSONHTMLDocumentBuffer { + var planning = options + if options.layout == .syntax { + // An explicit syntax invocation never invokes the inline/media pipeline. + planning.inlineFormatting = .off + planning.colorPreviews = false + planning.embeddedImagePreviews = false + planning.previewHints = [] + planning.effectivePreviewHints = [] + } + let plan = try JSONHTMLPreviewPlan.make(value, options: planning, meter: &meter) + var output = JSONHTMLDocumentBuffer(limits: options.limits) + output.setAnnotations(UInt64(annotations.count)) + output.setPreviews( + shortened: plan.shortened && options.valueProfile == .inspection + && options.layout == .structured, issues: UInt64(plan.issues.count), + images: plan.media.images, imageBytes: plan.media.bytes) + let interactive = options.presentation == .interactive + try output.addDOM(110, meter: &meter) + try output.append( + " 0 { try output.append("img-src data:; ", meter: &meter) } + try output.append( + "base-uri 'none'; form-action 'none'; object-src 'none'\">", meter: &meter) + try output.escaped(options.title, meter: &meter) + try output.append("

    ", meter: &meter) + try output.escaped(options.title, meter: &meter) + try output.append("

    ", meter: &meter) + try output.append( + options.valueProfile == .inspection && options.layout == .structured + ? "Inspection view · bounded string previews; exact values remain available." + : "Complete values · exact JSON source remains available.", meter: &meter) + try output.append("

    ", meter: &meter) + let copy = try nativeBytes(value, options: writingOptions, meter: &meter) + output.setCopyBytes(UInt64(copy.count)) + try output.append( + "

    Download exact JSON (UTF-8; works without JavaScript)

    ", meter: &meter) + if interactive { + try output.append( + "

      ", meter: &meter) + } + try meter.release(UInt64(copy.count)) + var outline: [StructuredOutline] = [] + if options.layout == .structured || interactive { + try output.append( + "
      ", + meter: &meter) + try structuredBody( + value, plan: plan, profile: options.valueProfile, namespace: "jh-n-", + interactive: interactive, countNodes: true, annotations: annotations, + outline: &outline, + output: &output, meter: &meter) + try output.append("
      ", meter: &meter) + if interactive, options.outlineDepth != nil { + try output.append( + "", meter: &meter) + } + try output.append("
      ", meter: &meter) + } + if interactive || options.layout == .syntax { + try output.append( + "

      Exact JSON source

      ",
      +                meter: &meter)
      +            try highlighted(value, options: writingOptions, output: &output, meter: &meter)
      +            try output.append("
      ", meter: &meter) + } else { + try output.append( + "
      Exact JSON source (complete)
      ",
      +                meter: &meter)
      +            try highlighted(value, options: writingOptions, output: &output, meter: &meter)
      +            try output.append("
      ", meter: &meter) + } + if !plan.issues.isEmpty { + try output.addDOM(UInt64(plan.issues.count) * 4 + 4, meter: &meter) + try output.append( + "

      Preview issues

        ", + meter: &meter) + for issue in plan.issues { + try meter.work() + try output.append("
      • ", meter: &meter) + let pointer = try pointerBytes(issue.path, meter: &meter) + try output.escaped(String(decoding: pointer, as: UTF8.self), meter: &meter) + try meter.release(UInt64(pointer.count)) + try output.append(": ", meter: &meter) + try output.escaped(issue.message, meter: &meter) + try output.append("
      • ", meter: &meter) + } + try output.append("
      ", meter: &meter) + } + try annotationSection(annotations, output: &output, meter: &meter) + // Printing is a separately charged representation of the same immutable plan/root. + try output.append( + "
      ", + meter: &meter) + if options.layout == .structured { + var ignored: [StructuredOutline] = [] + try structuredBody( + value, plan: plan, profile: options.printValueProfile, namespace: "jh-p-", + interactive: false, countNodes: false, annotations: [], outline: &ignored, + output: &output, + meter: &meter) + } else { + try output.append("
      ", meter: &meter)
      +            try highlighted(value, options: writingOptions, output: &output, meter: &meter)
      +            try output.append("
      ", meter: &meter) + } + try meter.release(plan.reservation) + try output.append("
      ", meter: &meter) + if interactive { + try output.append("", meter: &meter) + } + try output.append("\n", meter: &meter) + try meter.checkpoint() + return output + } + private var writingOptions: JSONWritingOptions { + .init( + limits: options.limits.core, indentation: options.indentation, numberStyle: .preserved, + indentationUnit: options.indentationUnit) + } + private func scalarFlow(_ values: [JSON], meter: inout JSONWorkMeter) throws -> Bool { + guard values.count <= 10 else { return false } + for value in values { + try meter.work() + var length: UInt64 = 0 + switch value { + case .array, .object: return false + case .string(let s): + length = 2 + for scalar in s.unicodeScalars { + try meter.work() + length += JSONHTMLDisplay.units(scalar) + if length > 60 { return false } + } + default: + var cursor = JSONWritingCursor(value: value, options: writingOptions) + while (try cursor.next(meter: &meter)) != nil { + length += 1 + if length > 60 { return false } + } + } + if length > 60 { return false } + } + return true + } + private func structuredBody( + _ value: JSON, plan: JSONHTMLPreviewPlan, profile: JSONHTMLValueProfile, namespace: String, + interactive: Bool, countNodes: Bool, annotations: [JSONHTMLBoundAnnotation], + outline: inout [StructuredOutline], output: inout JSONHTMLDocumentBuffer, + meter: inout JSONWorkMeter + ) throws { + var stack: [StructuredStep] = [] + var path: [JSONLocationComponent] = [] + var ordinal: UInt64 = 0 + func push(_ step: StructuredStep, _ m: inout JSONWorkMeter) throws { + try m.retain(JSONLogicalWeights.frame) + stack.append(step) + } + try push(.node(value, nil, 0), &meter) + while let step = stack.popLast() { + try meter.release(JSONLogicalWeights.frame) + try meter.work() + try meter.retain(256) + switch step { + case .end(let markup, let pop): + try output.append(markup, meter: &meter) + if pop { + path.removeLast() + try meter.release(JSONLogicalWeights.frame) + } + case .objectMember(let child, let key, let depth): + try output.addDOM(6, meter: &meter) + try output.append( + "
      ", + meter: &meter) + try JSONHTMLDisplay.text(key.string, quoted: true, output: &output, meter: &meter) + if key.string.isEmpty { + try output.append( + " Empty key", meter: &meter) + } + try output.append("
      ", meter: &meter) + try push(.end("
      ", false), &meter) + try push(.node(child, .key(key), depth), &meter) + case .arrayMember(let child, let index, let depth, let flow, let records): + try output.addDOM(6, meter: &meter) + try output.append( + flow + ? "
    • Index " + : records + ? "
    • #" + : "
    • #", + meter: &meter) + try output.decimal(UInt64(index), meter: &meter) + try output.append("", meter: &meter) + try push(.end("
    • ", false), &meter) + try push(.node(child, .index(index), depth), &meter) + case .node(let node, let component, let depth): + if let component { + try meter.retain(JSONLogicalWeights.frame) + path.append(component) + } + let id = ordinal + ordinal = try JSONHTMLDocumentBuffer.adding( + .renderedJSONNodes, ordinal, 1, options.limits.maxRenderedJSONNodes) + if countNodes { try output.addNode(meter: &meter) } + let container: Bool + let empty: Bool + let kind: String + switch node { + case .object(let o): + container = true + empty = o.isEmpty + kind = "object" + case .array(let a): + container = true + empty = a.isEmpty + kind = "array" + case .string: + container = false + empty = false + kind = "string" + case .number: + container = false + empty = false + kind = "number" + case .boolean: + container = false + empty = false + kind = "bool" + case .null: + container = false + empty = false + kind = "null" + } + if interactive, container, let maxDepth = options.outlineDepth, depth <= maxDepth { + try output.addOutline(meter: &meter) + try meter.retain(40) + outline.append(.init(ordinal: id, component: component, depth: depth)) + } + try output.addDOM(24, meter: &meter) + try output.append( + container && !empty + ? (interactive + ? "
      " : ">
      ") + : ">", + meter: &meter) + if !container { + try output.append("", meter: &meter) + try output.append( + kind == "bool" + ? "Boolean" + : kind == "null" ? "Null" : kind == "string" ? "String" : "Number", + meter: &meter) + try output.append("", meter: &meter) + } + if container { + try output.append("", meter: &meter) + try output.append(empty ? "Empty " + kind : kind, meter: &meter) + switch node { + case .object(let o): + try output.append(" · ", meter: &meter) + try output.decimal(UInt64(o.count), meter: &meter) + try output.append(" members", meter: &meter) + case .array(let a): + try output.append(" · ", meter: &meter) + try output.decimal(UInt64(a.count), meter: &meter) + try output.append(" items", meter: &meter) + default: break + } + try output.append("", meter: &meter) + } + if interactive { + try output.append( + "", meter: &meter) + } + let preview = plan.nodes[Int(id)] + if case .color(let color) = preview.primary { + try color.emit( + ordinal: id, namespace: namespace, label: preview.prefix, output: &output, + meter: &meter + ) + } + if case .image(let image) = preview.primary { + try image.emit(output: &output, meter: &meter) + } + switch node { + case .object(let members) where !members.isEmpty: + try output.append( + interactive + ? "
      " + : "
      ", + meter: &meter) + try push( + .end(interactive ? "
      " : "", component != nil), + &meter) + for member in members.reversed() { + try meter.work() + try push(.objectMember(member.value, member.key, depth + 1), &meter) + } + case .array(let values) where !values.isEmpty: + let flow = try scalarFlow(values, meter: &meter) + var records = false + for child in values { + try meter.work() + if case .object = child { + records = true + break + } + if case .array = child { + records = true + break + } + } + try output.append(interactive ? "" : "", meter: &meter) + try output.append( + flow ? "
        " : "
          ", + meter: &meter) + try push( + .end(interactive ? "
        " : "
      ", component != nil), + &meter) + for index in values.indices.reversed() { + try meter.work() + try push( + .arrayMember(values[index], index, depth + 1, flow, records), &meter) + } + case .string(let string): + let prefix = preview.prefix! + let usePrefix = profile == .inspection + try output.append("", meter: &meter) + if case .inline(let inline) = preview.primary, inline.formatted { + try output.append( + "Inline preview: ", meter: &meter) + try inline.emit(output: &output, meter: &meter) + } else { + try JSONHTMLDisplay.text( + usePrefix ? prefix.source : string, quoted: true, output: &output, + meter: &meter) + } + try output.append("", meter: &meter) + if prefix.shortened + && (usePrefix + || { + if case .inline = preview.primary { return true } + return false + }()) + { + try output.addDOM(4, meter: &meter) + try output.append( + "… String preview · first ", meter: &meter + ) + try output.decimal(prefix.scalars, meter: &meter) + try output.append( + " decoded scalars (bounded display)", meter: &meter) + } + let needsOriginal: Bool + switch preview.primary { + case .literal: needsOriginal = usePrefix && prefix.shortened + default: needsOriginal = true + } + if needsOriginal { + try output.addDOM(6, meter: &meter) + if profile == .complete { + try output.append( + "
      Original value (complete)", + meter: &meter) + } else { + try output.append( + "
      Original value (complete)", + meter: &meter) + } + try JSONHTMLDisplay.text( + string, quoted: true, output: &output, meter: &meter) + try output.append( + profile == .complete ? "
      " : "", + meter: &meter) + } + try push(.end("", component != nil), &meter) + default: + try output.append("", meter: &meter) + if empty { + try output.append(kind == "object" ? "{}" : "[]", meter: &meter) + } else { + try highlighted( + node, options: writingOptions, output: &output, meter: &meter) + } + try output.append("", meter: &meter) + try push(.end("", component != nil), &meter) + } + } + try meter.release(256) + } + } +} diff --git a/Sources/JSONHTML/JSONPDFBackend.swift b/Sources/JSONHTML/JSONPDFBackend.swift index 2a990eb..020e223 100644 --- a/Sources/JSONHTML/JSONPDFBackend.swift +++ b/Sources/JSONHTML/JSONPDFBackend.swift @@ -1,224 +1,361 @@ // SPDX-License-Identifier: Apache-2.0 #if canImport(WebKit) && canImport(CoreGraphics) -import Foundation -import WebKit -import CoreGraphics -import CoreText -#if canImport(AppKit) -import AppKit -#elseif canImport(UIKit) -import UIKit -#endif + import Foundation + import WebKit + import CoreGraphics + import CoreText + #if canImport(AppKit) + import AppKit + #elseif canImport(UIKit) + import UIKit + #endif -@MainActor package protocol JSONPDFPageBackend: AnyObject { - func load(_ html: String) async throws - func layout(width: Double) async throws -> JSONPDFGeometry - func textInfo() async throws -> JSONPDFTextInfo - func textRun(at index: Int) async throws -> JSONPDFTextRun - func capture(_ rectangle: JSONPDFRectangle) async throws -> Data - func stop() -} + @MainActor package protocol JSONPDFPageBackend: AnyObject { + func load(_ html: String) async throws + func layout(width: Double) async throws -> JSONPDFGeometry + func textInfo() async throws -> JSONPDFTextInfo + func textRun(at index: Int) async throws -> JSONPDFTextRun + func beginFragments(height: Double) async throws + func nextFragment() async throws -> JSONPDFFragmentStep + func captureRowCount() async throws -> Int + func setCaptureVisibility(at index: Int, region: JSONPDFRectangle) async throws + func capture(_ rectangle: JSONPDFRectangle) async throws -> Data + func stop() + } -private struct JSONPDFOfflineNavigation: WebPage.NavigationDeciding { - @MainActor mutating func decidePolicy(for action: WebPage.NavigationAction, - preferences: inout WebPage.NavigationPreferences) async -> WKNavigationActionPolicy { - preferences.allowsContentJavaScript = false - return action.request.url?.absoluteString == "about:blank" ? .allow : .cancel + extension JSONPDFPageBackend { + package func captureRowCount() async throws -> Int { 0 } + package func setCaptureVisibility(at index: Int, region: JSONPDFRectangle) async throws {} + package func beginFragments(height: Double) async throws { + throw JSONPDFFailure.layoutFailed + } + package func nextFragment() async throws -> JSONPDFFragmentStep { + throw JSONPDFFailure.layoutFailed + } } -} -@MainActor package final class JSONPDFWebPage: JSONPDFPageBackend { - private var page: WebPage? - package init() { - var configuration = WebPage.Configuration() - configuration.websiteDataStore = .nonPersistent() - configuration.loadsSubresources = false - configuration.defaultNavigationPreferences.allowsContentJavaScript = false - configuration.deviceSensorAuthorization = .init(decision: .deny) - let page = WebPage(configuration: configuration, navigationDecider: JSONPDFOfflineNavigation()) - page.mediaType = .print - self.page = page + private struct JSONPDFOfflineNavigation: WebPage.NavigationDeciding { + @MainActor mutating func decidePolicy( + for action: WebPage.NavigationAction, + preferences: inout WebPage.NavigationPreferences + ) async -> WKNavigationActionPolicy { + preferences.allowsContentJavaScript = false + return action.request.url?.absoluteString == "about:blank" ? .allow : .cancel + } } - package func load(_ html: String) async throws { - guard let page else { throw CancellationError() } - var events = 0 - do { - for try await event in page.load(html: html) { - try Task.checkCancellation() - events += 1 - guard events <= 8 else { throw JSONPDFFailure.navigationFailed } - if event == .finished { return } + + @MainActor package final class JSONPDFWebPage: JSONPDFPageBackend { + private var page: WebPage? + package init() { + var configuration = WebPage.Configuration() + configuration.websiteDataStore = .nonPersistent() + // Generated artifacts have default-src none and img-src data: only when + // native media was admitted. Permit those data images; no remote source is emitted. + configuration.loadsSubresources = true + configuration.defaultNavigationPreferences.allowsContentJavaScript = false + configuration.deviceSensorAuthorization = .init(decision: .deny) + let page = WebPage( + configuration: configuration, navigationDecider: JSONPDFOfflineNavigation()) + page.mediaType = .print + self.page = page + } + package func load(_ html: String) async throws { + guard let page else { throw CancellationError() } + var events = 0 + do { + for try await event in page.load(html: html) { + try Task.checkCancellation() + events += 1 + guard events <= 8 else { throw JSONPDFFailure.navigationFailed } + if event == .finished { return } + } + } catch is CancellationError { throw CancellationError() } catch { + throw JSONPDFFailure.navigationFailed } - } catch is CancellationError { throw CancellationError() } - catch { throw JSONPDFFailure.navigationFailed } - throw JSONPDFFailure.navigationFailed - } - package func layout(width: Double) async throws -> JSONPDFGeometry { - guard let page else { throw CancellationError() } - do { - // Fixed client-world print-profile code; content JS stays disabled. Fonts - // readiness and a synchronous layout query replace correctness sleeps. - let value = try await page.callJavaScript( - "const s=document.styleSheets[0]; s.insertRule('body{margin:0!important;padding:0!important;width:'+width+'px!important;background:#fff!important;color:#000!important}',s.cssRules.length); s.insertRule('main{margin:0!important}',s.cssRules.length); s.insertRule('h1{display:none!important}',s.cssRules.length); s.insertRule('pre{margin:0!important;white-space:pre-wrap!important;overflow-wrap:anywhere!important;font:11px/16px Menlo,monospace!important}',s.cssRules.length); await document.fonts.ready; const p=document.querySelector('pre'); const r=p.getBoundingClientRect(); return [r.width,r.height,parseFloat(getComputedStyle(p).lineHeight),document.fonts.status==='loaded'?1:0,r.x,r.y];", - arguments: ["width": width], contentWorld: .defaultClient) - guard let values = value as? [Double], values.count == 6, values[3] == 1, - values[4] == 0, values[5] == 0 else { throw JSONPDFFailure.layoutFailed } - return .init(width: values[0], height: values[1], lineHeight: values[2]) - } catch is CancellationError { throw CancellationError() } - catch { throw JSONPDFFailure.layoutFailed } - } - package func textInfo() async throws -> JSONPDFTextInfo { - guard let page else { throw CancellationError() } - do { - let result = try await page.callJavaScript( - "return [getComputedStyle(document.querySelector('pre')).tabSize,document.querySelector('pre code').childNodes.length];", - contentWorld: .defaultClient) - guard let values = result as? [Any], values.count == 2, - let tab = values[0] as? String, let tabWidth = Double(tab), (1...8).contains(tabWidth), - let count = values[1] as? Int, count > 0 else { throw JSONPDFFailure.layoutFailed } - return .init(count: count, tabWidth: tabWidth) - } catch is CancellationError { throw CancellationError() } - catch { throw JSONPDFFailure.layoutFailed } - } - package func textRun(at index: Int) async throws -> JSONPDFTextRun { - guard let page else { throw CancellationError() } - do { - // One native DOM primitive per call: no uninterruptible owned JS loop - // and no owned run array allocated outside the job's cumulative meter. - let result = try await page.callJavaScript( - "const t=document.querySelector('pre code').childNodes.item(index);if(!t)return null;return [t.nodeType===3?'whitespace':t.className,t.textContent];", - arguments: ["index": index], contentWorld: .defaultClient) - guard let row = result as? [String], row.count == 2, - ["whitespace", "punctuation", "key", "string", "number", "boolean", "null"].contains(row[0]) else { + throw JSONPDFFailure.navigationFailed + } + package func layout(width: Double) async throws -> JSONPDFGeometry { + guard let page else { throw CancellationError() } + do { + // Fixed client-world print-profile code; content JS stays disabled. Fonts + // readiness and a synchronous layout query replace correctness sleeps. + let value = try await page.callJavaScript( + "const s=document.styleSheets[0]; s.insertRule('body{margin:0!important;padding:0!important;width:'+width+'px!important;background:#fff!important;color:#000!important}',s.cssRules.length); s.insertRule('main{margin:0!important;padding:0!important;max-width:none!important}',s.cssRules.length); s.insertRule('h1{display:none!important}',s.cssRules.length); s.insertRule('.rich .print-json{display:flow-root!important;margin:0!important;padding:0!important}',s.cssRules.length); s.insertRule('.rich .source-download,.rich .screen-view,.rich .document-header,.rich .exact-source,.rich .view-tabs,.rich .preview-issues,.rich .toolbar,.rich #jh-status,.rich .search-results,.rich dialog,.rich noscript{display:none!important}',s.cssRules.length); s.insertRule('pre{margin:0!important;white-space:pre-wrap!important;overflow-wrap:anywhere!important;font:11px/16px Menlo,monospace!important}',s.cssRules.length); await document.fonts.ready; const images=document.querySelectorAll('.print-json img'); for(const image of images){await image.decode();if(!image.complete||image.naturalWidth===0)throw new Error('Image decode failed');} const rich=document.body.classList.contains('rich'); const p=rich?document.querySelector('.print-json'):document.querySelector('pre'); const r=p.getBoundingClientRect(); return [r.width,r.height,16,document.fonts.status==='loaded'?1:0,r.x,r.y];", + arguments: ["width": width], contentWorld: .defaultClient) + guard let values = value as? [Double], values.count == 6, values[3] == 1, + values[4] == 0, values[5] == 0 + else { throw JSONPDFFailure.layoutFailed } + return .init(width: values[0], height: ceil(values[1]), lineHeight: values[2]) + } catch is CancellationError { throw CancellationError() } catch { throw JSONPDFFailure.layoutFailed } - return .init(kind: row[0], text: row[1]) - } catch is CancellationError { throw CancellationError() } - catch { throw JSONPDFFailure.layoutFailed } - } - package func capture(_ rectangle: JSONPDFRectangle) async throws -> Data { - guard let page else { throw CancellationError() } - do { - return try await page.exported(as: .pdf(region: .rect(CGRect(x: rectangle.x, - y: rectangle.y, width: rectangle.width, height: rectangle.height)))) - } catch is CancellationError { throw CancellationError() } - catch { throw JSONPDFFailure.captureFailed } + } + package func textInfo() async throws -> JSONPDFTextInfo { + guard let page else { throw CancellationError() } + do { + let result = try await page.callJavaScript( + "return [getComputedStyle(document.querySelector('pre')).tabSize,document.querySelector('pre code').childNodes.length];", + contentWorld: .defaultClient) + guard let values = result as? [Any], values.count == 2, + let tab = values[0] as? String, let tabWidth = Double(tab), + (1...8).contains(tabWidth), + let count = values[1] as? Int, count > 0 + else { throw JSONPDFFailure.layoutFailed } + return .init(count: count, tabWidth: tabWidth) + } catch is CancellationError { throw CancellationError() } catch { + throw JSONPDFFailure.layoutFailed + } + } + package func textRun(at index: Int) async throws -> JSONPDFTextRun { + guard let page else { throw CancellationError() } + do { + // One native DOM primitive per call: no uninterruptible owned JS loop + // and no owned run array allocated outside the job's cumulative meter. + let result = try await page.callJavaScript( + "const t=document.querySelector('pre code').childNodes.item(index);if(!t)return null;return [t.nodeType===3?'whitespace':t.className,t.textContent];", + arguments: ["index": index], contentWorld: .defaultClient) + guard let row = result as? [String], row.count == 2, + ["whitespace", "punctuation", "key", "string", "number", "boolean", "null"] + .contains(row[0]) + else { + throw JSONPDFFailure.layoutFailed + } + return .init(kind: row[0], text: row[1]) + } catch is CancellationError { throw CancellationError() } catch { + throw JSONPDFFailure.layoutFailed + } + } + package func beginFragments(height: Double) async throws { + guard let page else { throw CancellationError() } + do { + _ = try await page.callJavaScript( + "const root=document.querySelector('.print-json'); window.__jhPDFScan={walker:document.createTreeWalker(root,NodeFilter.SHOW_TEXT),rects:null,index:0,media:root.querySelectorAll('img,math,.color-swatch,.scalar-item,.record-card'),mediaIndex:0,height:height}; return 1;", + arguments: ["height": height], contentWorld: .defaultClient) + } catch is CancellationError { throw CancellationError() } catch { + throw JSONPDFFailure.layoutFailed + } + } + package func nextFragment() async throws -> JSONPDFFragmentStep { + guard let page else { throw CancellationError() } + do { + // At most one text node/media element/rectangle per invocation. DOM + // lists are SDK state; no owned JavaScript loop or generated array grows. + let value = try await page.callJavaScript( + "const s=window.__jhPDFScan;if(s.rects&&s.indexs.height)return [0,0,0];return [1,r.top,r.bottom];}delete window.__jhPDFScan;return [2,0,0];", + contentWorld: .defaultClient) + guard let values = value as? [Double], values.count == 3 else { + throw JSONPDFFailure.layoutFailed + } + switch values[0] { + case 0: return .advance + case 1: return .span(.init(top: values[1], bottom: values[2])) + case 2: return .end + default: throw JSONPDFFailure.layoutFailed + } + } catch is CancellationError { throw CancellationError() } catch { + throw JSONPDFFailure.layoutFailed + } + } + package func captureRowCount() async throws -> Int { + guard let page else { throw CancellationError() } + do { + let result = try await page.callJavaScript( + "window.__jhPDFRows=document.querySelectorAll('.print-json .scalar-item,.print-json .record-card');return window.__jhPDFRows.length;", + contentWorld: .defaultClient) + guard let count = result as? Int, count >= 0 else { + throw JSONPDFFailure.layoutFailed + } + return count + } catch is CancellationError { throw CancellationError() } catch { + throw JSONPDFFailure.layoutFailed + } + } + package func setCaptureVisibility(at index: Int, region: JSONPDFRectangle) async throws { + guard let page else { throw CancellationError() } + do { + _ = try await page.callJavaScript( + "const e=window.__jhPDFRows.item(index);if(!e)throw new Error('Missing row');const r=e.getBoundingClientRect();e.style.visibility=r.bottom<=top||r.top>=bottom?'hidden':'visible';return 1;", + arguments: [ + "index": index, "top": region.y, "bottom": region.y + region.height, + ], contentWorld: .defaultClient) + } catch is CancellationError { throw CancellationError() } catch { + throw JSONPDFFailure.layoutFailed + } + } + package func capture(_ rectangle: JSONPDFRectangle) async throws -> Data { + guard let page else { throw CancellationError() } + do { + return try await page.exported( + as: .pdf( + region: .rect( + CGRect( + x: rectangle.x, + y: rectangle.y, width: rectangle.width, height: rectangle.height)))) + } catch is CancellationError { throw CancellationError() } catch { + throw JSONPDFFailure.captureFailed + } + } + package func stop() { + page?.stopLoading() + page = nil + } } - package func stop() { page?.stopLoading(); page = nil } -} -// CoreGraphics objects are invocation-owned on a separate actor. A synchronous -// SDK draw may finish after the main-actor deadline; no task-group loser is awaited. -package actor JSONPDFCompositor { - private var buffer: CFMutableData? - private var context: CGContext? - private let width: Double - private let height: Double - private let margin: Double - private var pages: UInt64 = 0 - private var setter: CTFramesetter? - private var textLength = 0 - private var textOffset = 0 - package func configure(_ source: JSONPDFText) throws { - try Task.checkCancellation() - let font = CTFontCreateWithName("Menlo-Regular" as CFString, 11, nil) - let paragraph = NSMutableParagraphStyle() - paragraph.minimumLineHeight = 16; paragraph.maximumLineHeight = 16 - paragraph.lineBreakMode = .byCharWrapping - paragraph.defaultTabInterval = 6.6 * source.tabWidth - paragraph.tabStops = [] - let value = NSMutableAttributedString(string: "") - for run in source.runs { + // CoreGraphics objects are invocation-owned on a separate actor. A synchronous + // SDK draw may finish after the main-actor deadline; no task-group loser is awaited. + package actor JSONPDFCompositor { + private var buffer: CFMutableData? + private var context: CGContext? + private let width: Double + private let height: Double + private let margin: Double + private var pages: UInt64 = 0 + private var setter: CTFramesetter? + private var textLength = 0 + private var textOffset = 0 + package func configure(_ source: JSONPDFText) throws { try Task.checkCancellation() - let rgb: (Double, Double, Double) - switch run.kind { - case "key": rgb = (102/255.0, 59/255.0, 131/255.0) - case "string": rgb = (22/255.0, 99/255.0, 58/255.0) - case "number": rgb = (7/255.0, 94/255.0, 146/255.0) - case "boolean", "null": rgb = (147/255.0, 70/255.0, 27/255.0) - default: rgb = (0, 0, 0) - } - value.append(NSAttributedString(string: run.text, attributes: [ - NSAttributedString.Key(kCTFontAttributeName as String): font, - NSAttributedString.Key(kCTForegroundColorAttributeName as String): CGColor(red: rgb.0, green: rgb.1, blue: rgb.2, alpha: 1), - .paragraphStyle: paragraph])) - } - guard value.length > 0 else { throw JSONPDFFailure.layoutFailed } - textLength = value.length; textOffset = 0 - setter = CTFramesetterCreateWithAttributedString(value) - } - package func nextTextPage(contentWidth: Double, contentHeight: Double) throws -> JSONPDFTextPage { - try Task.checkCancellation() - guard let setter, textOffset < textLength, - let data = CFDataCreateMutable(nil, 0), let consumer = CGDataConsumer(data: data) else { - throw JSONPDFFailure.captureFailed - } - // Safety: the SDK initializer reads one live, initialized CGRect during - // this synchronous call. No pointer is retained in Swift or crosses actors. - var box = CGRect(x: 0, y: 0, width: contentWidth, height: contentHeight) - guard let pageContext = unsafe CGContext(consumer: consumer, mediaBox: &box, nil) else { throw JSONPDFFailure.captureFailed } - let path = CGMutablePath(); path.addRect(box) - let frame = CTFramesetterCreateFrame(setter, CFRange(location: textOffset, length: 0), path, nil) - let range = CTFrameGetVisibleStringRange(frame) - guard range.location == textOffset, range.length > 0, range.length <= textLength - textOffset else { - pageContext.closePDF(); throw JSONPDFFailure.layoutFailed - } - pageContext.beginPDFPage(nil); CTFrameDraw(frame, pageContext) - pageContext.endPDFPage(); pageContext.closePDF() - textOffset += range.length - let lines = CFArrayGetCount(CTFrameGetLines(frame)) - try Task.checkCancellation() - let (heightPoints, overflow) = UInt64(lines).multipliedReportingOverflow(by: 16) - guard !overflow else { throw JSONPDFFailure.counterOverflow(.contentHeightPoints) } - return .init(data: data as Data, isComplete: textOffset == textLength, - utf16Units: UInt64(range.length), heightPoints: heightPoints) - } - package init(width: Double, height: Double, margin: Double) throws { - self.width = width; self.height = height; self.margin = margin - guard let buffer = CFDataCreateMutable(nil, 0), let consumer = CGDataConsumer(data: buffer) else { - throw JSONPDFFailure.captureFailed - } - // Safety: the SDK initializer reads one live, initialized CGRect during - // this synchronous call. No pointer is retained in Swift or crosses actors. - var box = CGRect(x: 0, y: 0, width: width, height: height) - guard let context = unsafe CGContext(consumer: consumer, mediaBox: &box, nil) else { - throw JSONPDFFailure.captureFailed - } - self.buffer = buffer; self.context = context - } - package func append(_ data: Data, region: JSONPDFRectangle) throws { - try Task.checkCancellation() - guard let context, let provider = CGDataProvider(data: data as CFData), - let source = CGPDFDocument(provider), source.numberOfPages == 1, - let page = source.page(at: 1), !source.isEncrypted else { - throw JSONPDFFailure.captureFailed - } - let bounds = page.getBoxRect(.mediaBox) - guard bounds.minX == 0, bounds.minY == 0, abs(bounds.width - region.width) < 0.1, abs(bounds.height - region.height) < 0.1 else { - throw JSONPDFFailure.captureFailed - } - context.beginPDFPage(nil); context.saveGState() - context.setFillColor(CGColor(gray: 1, alpha: 1)) - context.fill(CGRect(x: 0, y: 0, width: width, height: height)) - context.translateBy(x: margin, y: height - margin - region.height) - context.drawPDFPage(page) - context.restoreGState(); context.endPDFPage() - pages += 1 - try Task.checkCancellation() - } - package func finish(maximumBytes: UInt64, expectedPages: UInt64) throws -> Data { - try Task.checkCancellation() - guard let context, let buffer, pages == expectedPages else { throw JSONPDFFailure.captureFailed } - context.closePDF(); self.context = nil; self.buffer = nil; self.setter = nil - let count = UInt64(CFDataGetLength(buffer)) - try pdfRequire(.pdfBytes, count, maximumBytes) - // Opaque native buffer materialization; the caller charges accepted bytes - // before retaining/installing its immutable result. - let data = buffer as Data - guard let provider = CGDataProvider(data: data as CFData), let document = CGPDFDocument(provider), - UInt64(document.numberOfPages) == expectedPages else { throw JSONPDFFailure.captureFailed } - return data + let font = CTFontCreateWithName("Menlo-Regular" as CFString, 11, nil) + let paragraph = NSMutableParagraphStyle() + paragraph.minimumLineHeight = 16 + paragraph.maximumLineHeight = 16 + paragraph.lineBreakMode = .byCharWrapping + paragraph.defaultTabInterval = 6.6 * source.tabWidth + paragraph.tabStops = [] + let value = NSMutableAttributedString(string: "") + for run in source.runs { + try Task.checkCancellation() + let rgb: (Double, Double, Double) + switch run.kind { + case "key": rgb = (102 / 255.0, 59 / 255.0, 131 / 255.0) + case "string": rgb = (22 / 255.0, 99 / 255.0, 58 / 255.0) + case "number": rgb = (7 / 255.0, 94 / 255.0, 146 / 255.0) + case "boolean", "null": rgb = (147 / 255.0, 70 / 255.0, 27 / 255.0) + default: rgb = (0, 0, 0) + } + value.append( + NSAttributedString( + string: run.text, + attributes: [ + NSAttributedString.Key(kCTFontAttributeName as String): font, + NSAttributedString.Key(kCTForegroundColorAttributeName as String): + CGColor(red: rgb.0, green: rgb.1, blue: rgb.2, alpha: 1), + .paragraphStyle: paragraph, + ])) + } + guard value.length > 0 else { throw JSONPDFFailure.layoutFailed } + textLength = value.length + textOffset = 0 + setter = CTFramesetterCreateWithAttributedString(value) + } + package func nextTextPage(contentWidth: Double, contentHeight: Double) throws + -> JSONPDFTextPage + { + try Task.checkCancellation() + guard let setter, textOffset < textLength, + let data = CFDataCreateMutable(nil, 0), let consumer = CGDataConsumer(data: data) + else { + throw JSONPDFFailure.captureFailed + } + // Safety: the SDK initializer reads one live, initialized CGRect during + // this synchronous call. No pointer is retained in Swift or crosses actors. + var box = CGRect(x: 0, y: 0, width: contentWidth, height: contentHeight) + guard let pageContext = unsafe CGContext(consumer: consumer, mediaBox: &box, nil) else { + throw JSONPDFFailure.captureFailed + } + let path = CGMutablePath() + path.addRect(box) + let frame = CTFramesetterCreateFrame( + setter, CFRange(location: textOffset, length: 0), path, nil) + let range = CTFrameGetVisibleStringRange(frame) + guard range.location == textOffset, range.length > 0, + range.length <= textLength - textOffset + else { + pageContext.closePDF() + throw JSONPDFFailure.layoutFailed + } + pageContext.beginPDFPage(nil) + CTFrameDraw(frame, pageContext) + pageContext.endPDFPage() + pageContext.closePDF() + textOffset += range.length + let lines = CFArrayGetCount(CTFrameGetLines(frame)) + try Task.checkCancellation() + let (heightPoints, overflow) = UInt64(lines).multipliedReportingOverflow(by: 16) + guard !overflow else { throw JSONPDFFailure.counterOverflow(.contentHeightPoints) } + return .init( + data: data as Data, isComplete: textOffset == textLength, + utf16Units: UInt64(range.length), heightPoints: heightPoints) + } + package init(width: Double, height: Double, margin: Double) throws { + self.width = width + self.height = height + self.margin = margin + guard let buffer = CFDataCreateMutable(nil, 0), + let consumer = CGDataConsumer(data: buffer) + else { + throw JSONPDFFailure.captureFailed + } + // Safety: the SDK initializer reads one live, initialized CGRect during + // this synchronous call. No pointer is retained in Swift or crosses actors. + var box = CGRect(x: 0, y: 0, width: width, height: height) + guard let context = unsafe CGContext(consumer: consumer, mediaBox: &box, nil) else { + throw JSONPDFFailure.captureFailed + } + self.buffer = buffer + self.context = context + } + package func append(_ data: Data, region: JSONPDFRectangle) throws { + try Task.checkCancellation() + guard let context, let provider = CGDataProvider(data: data as CFData), + let source = CGPDFDocument(provider), source.numberOfPages == 1, + let page = source.page(at: 1), !source.isEncrypted + else { + throw JSONPDFFailure.captureFailed + } + let bounds = page.getBoxRect(.mediaBox) + guard bounds.minX == 0, bounds.minY == 0, abs(bounds.width - region.width) < 0.1, + abs(bounds.height - region.height) < 0.1 + else { + throw JSONPDFFailure.captureFailed + } + context.beginPDFPage(nil) + context.saveGState() + context.setFillColor(CGColor(gray: 1, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + context.translateBy(x: margin, y: height - margin - region.height) + context.clip(to: CGRect(x: 0, y: 0, width: region.width, height: region.height)) + context.drawPDFPage(page) + context.restoreGState() + context.endPDFPage() + pages += 1 + try Task.checkCancellation() + } + package func finish(maximumBytes: UInt64, expectedPages: UInt64) throws -> Data { + try Task.checkCancellation() + guard let context, let buffer, pages == expectedPages else { + throw JSONPDFFailure.captureFailed + } + context.closePDF() + self.context = nil + self.buffer = nil + self.setter = nil + let count = UInt64(CFDataGetLength(buffer)) + try pdfRequire(.pdfBytes, count, maximumBytes) + // Opaque native buffer materialization; the caller charges accepted bytes + // before retaining/installing its immutable result. + let data = buffer as Data + guard let provider = CGDataProvider(data: data as CFData), + let document = CGPDFDocument(provider), + UInt64(document.numberOfPages) == expectedPages + else { throw JSONPDFFailure.captureFailed } + return data + } + package func stop() { + context?.closePDF() + context = nil + buffer = nil + setter = nil + } } - package func stop() { context?.closePDF(); context = nil; buffer = nil; setter = nil } -} #endif diff --git a/Sources/JSONHTML/JSONPDFOptions.swift b/Sources/JSONHTML/JSONPDFOptions.swift index ac2daf6..bc0be6f 100644 --- a/Sources/JSONHTML/JSONPDFOptions.swift +++ b/Sources/JSONHTML/JSONPDFOptions.swift @@ -151,6 +151,14 @@ public struct JSONPDFPreparationReport: Sendable { public let pageCount: UInt64 /// Actual backend identity: CoreText pagination or WebPage continuous capture. public let backend: String + /// Configured native HTML shape retained by the immutable input artifact. + public let htmlLayout: JSONHTMLLayout + /// Actual print profile used for capture. + public let valueProfile: JSONHTMLValueProfile + /// Native backend font readiness policy; no font download. + public let fontReadiness: String + /// Native backend awaits admitted print-image decoding before capture. + public let mediaReadiness: String /// Actual elapsed time, including platform waits; not an owned-work quota. public let elapsed: Duration } @@ -165,6 +173,8 @@ package func pdfAdd(_ metric: JSONPDFResourceMetric, _ a: UInt64, _ b: UInt64, _ } package enum JSONPDFStage: String, Sendable { case input, loading, layout, extraction, capture, composition, installation } package struct JSONPDFGeometry: Sendable { let width: Double; let height: Double; let lineHeight: Double } +package struct JSONPDFFragment: Sendable { let top: Double; let bottom: Double } +package enum JSONPDFFragmentStep: Sendable { case advance, span(JSONPDFFragment), end } package struct JSONPDFRectangle: Sendable { let x: Double; let y: Double; let width: Double; let height: Double } package struct JSONPDFTextRun: Sendable { let kind: String; let text: String } diff --git a/Sources/JSONHTML/JSONPDFPreparation.swift b/Sources/JSONHTML/JSONPDFPreparation.swift index 3c868c0..58cd633 100644 --- a/Sources/JSONHTML/JSONPDFPreparation.swift +++ b/Sources/JSONHTML/JSONPDFPreparation.swift @@ -3,7 +3,7 @@ import Foundation import JSON #if canImport(WebKit) && canImport(CoreGraphics) -import Synchronization + import Synchronization #endif extension JSONExportDocument { @@ -25,220 +25,372 @@ extension JSONExportDocument { /// - options: Qualified geometry, finite capacities and responsiveness policy. /// - Returns: Complete immutable artifacts and a distinct PDF operation report. /// - Throws: Missing HTML, configuration, capacity, backend or cancellation failure. - @MainActor public func preparingPDFWithReport(options: JSONPDFOptions = .init()) async throws -> JSONPDFPreparationReport { + @MainActor public func preparingPDFWithReport(options: JSONPDFOptions = .init()) async throws + -> JSONPDFPreparationReport + { #if canImport(WebKit) && canImport(CoreGraphics) - return try await preparingPDFWithReport(options: options, factory: { JSONPDFWebPage() }, observer: nil) + return try await preparingPDFWithReport( + options: options, factory: { JSONPDFWebPage() }, observer: nil) #else - try Task.checkCancellation(); try options.validate(); _ = try pdfInput() - throw JSONPDFFailure.backendUnavailable + try Task.checkCancellation() + try options.validate() + _ = try pdfInput() + throw JSONPDFFailure.backendUnavailable #endif } #if canImport(WebKit) && canImport(CoreGraphics) - @MainActor package func preparingPDFWithReport(options: JSONPDFOptions, - factory: @escaping @MainActor () -> any JSONPDFPageBackend, - observer: (@MainActor (JSONPDFStage) -> Void)?) async throws -> JSONPDFPreparationReport { - try Task.checkCancellation(); try options.validate() - let job = try JSONPDFJob(document: self, options: options, factory: factory, observer: observer) - return try await job.run() - } + @MainActor package func preparingPDFWithReport( + options: JSONPDFOptions, + factory: @escaping @MainActor () -> any JSONPDFPageBackend, + observer: (@MainActor (JSONPDFStage) -> Void)? + ) async throws -> JSONPDFPreparationReport { + try Task.checkCancellation() + try options.validate() + let job = try JSONPDFJob( + document: self, options: options, factory: factory, observer: observer) + return try await job.run() + } #endif } #if canImport(WebKit) && canImport(CoreGraphics) -private final class JSONPDFCancellationState: Sendable { - private let state = Mutex(false) - func cancel() { state.withLock { $0 = true } } - var isCancelled: Bool { state.withLock { $0 } } -} -@MainActor private final class JSONPDFJob { - private var document: JSONExportDocument? - private let options: JSONPDFOptions - private let factory: @MainActor () -> any JSONPDFPageBackend - private let observer: (@MainActor (JSONPDFStage) -> Void)? - private var meter: JSONWorkMeter - private var backend: (any JSONPDFPageBackend)? - private var compositor: JSONPDFCompositor? - private var worker: Task? - private var timer: Task? - private var continuation: CheckedContinuation? - private var terminal = false - private let cancellation = JSONPDFCancellationState() - private let started = ContinuousClock.now - private let deadline: ContinuousClock.Instant - private var ownedSinceCheck: UInt64 = 0 - - init(document: JSONExportDocument, options: JSONPDFOptions, - factory: @escaping @MainActor () -> any JSONPDFPageBackend, - observer: (@MainActor (JSONPDFStage) -> Void)?) throws { - self.document = document; self.options = options; self.factory = factory; self.observer = observer - meter = try JSONWorkMeter(limits: options.limits.core, domain: .writing) - deadline = started.advanced(by: .milliseconds(Int64(options.timeoutMilliseconds))) + private final class JSONPDFCancellationState: Sendable { + private let state = Mutex(false) + func cancel() { state.withLock { $0 = true } } + var isCancelled: Bool { state.withLock { $0 } } } - func run() async throws -> JSONPDFPreparationReport { - try Task.checkCancellation() - let cancellation = self.cancellation - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - self.continuation = continuation - worker = Task { [self] in - do { finish(.success(try await prepare())) } - catch { finish(.failure(error)) } - } - timer = Task { [weak self, deadline] in - do { - try await ContinuousClock().sleep(until: deadline) - self?.finish(.failure(JSONPDFFailure.timedOut)) - } catch { /* terminal path cancels only this invocation's timer */ } + @MainActor private final class JSONPDFJob { + private var document: JSONExportDocument? + private let options: JSONPDFOptions + private let factory: @MainActor () -> any JSONPDFPageBackend + private let observer: (@MainActor (JSONPDFStage) -> Void)? + private var meter: JSONWorkMeter + private var backend: (any JSONPDFPageBackend)? + private var compositor: JSONPDFCompositor? + private var worker: Task? + private var timer: Task? + private var continuation: CheckedContinuation? + private var terminal = false + private let cancellation = JSONPDFCancellationState() + private let started = ContinuousClock.now + private let deadline: ContinuousClock.Instant + private var ownedSinceCheck: UInt64 = 0 + + init( + document: JSONExportDocument, options: JSONPDFOptions, + factory: @escaping @MainActor () -> any JSONPDFPageBackend, + observer: (@MainActor (JSONPDFStage) -> Void)? + ) throws { + self.document = document + self.options = options + self.factory = factory + self.observer = observer + meter = try JSONWorkMeter(limits: options.limits.core, domain: .writing) + deadline = started.advanced(by: .milliseconds(Int64(options.timeoutMilliseconds))) + } + func run() async throws -> JSONPDFPreparationReport { + try Task.checkCancellation() + let cancellation = self.cancellation + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + worker = Task { [self] in + do { finish(.success(try await prepare())) } catch { + finish(.failure(error)) + } + } + timer = Task { [weak self, deadline] in + do { + try await ContinuousClock().sleep(until: deadline) + self?.finish(.failure(JSONPDFFailure.timedOut)) + } catch { /* terminal path cancels only this invocation's timer */ } + } } + } onCancel: { + cancellation.cancel() + Task { @MainActor [weak self] in self?.finish(.failure(CancellationError())) } } - } onCancel: { - cancellation.cancel() - Task { @MainActor [weak self] in self?.finish(.failure(CancellationError())) } } - } - private func finish(_ result: Result) { - guard !terminal else { return } - let result: Result = cancellation.isCancelled - ? .failure(CancellationError()) : result - terminal = true - if case .failure(let error) = result { meter.recordFailure(error) } - backend?.stop(); backend = nil - if let compositor { Task { await compositor.stop() } } - compositor = nil; document = nil - timer?.cancel(); timer = nil; worker?.cancel(); worker = nil - continuation?.resume(with: result); continuation = nil - } - private func check() throws { - guard !terminal, !cancellation.isCancelled else { throw CancellationError() } - try meter.checkpoint() - if ContinuousClock.now >= deadline { throw JSONPDFFailure.timedOut } - } - private func advance(_ stage: JSONPDFStage) throws { - try check(); observer?(stage); try check() - } - private func work() throws { - try meter.work(); ownedSinceCheck += 1 - if ownedSinceCheck >= 1_024 { ownedSinceCheck = 0; try check() } - } - private func retainBytes(_ bytes: Data, output: Bool) throws { - for _ in bytes { - try work(); if output { try meter.charge(.outputBytes) }; try meter.retain(1) + private func finish(_ result: Result) { + guard !terminal else { return } + let result: Result = + cancellation.isCancelled + ? .failure(CancellationError()) : result + terminal = true + if case .failure(let error) = result { meter.recordFailure(error) } + backend?.stop() + backend = nil + if let compositor { Task { await compositor.stop() } } + compositor = nil + document = nil + timer?.cancel() + timer = nil + worker?.cancel() + worker = nil + continuation?.resume(with: result) + continuation = nil } - try check() - } - private func prepare() async throws -> JSONPDFPreparationReport { - try advance(.input) - guard let document else { throw CancellationError() } - try meter.work(UInt64(document.availableFormats.count)) - let input = try document.pdfInput() - try pdfRequire(.htmlBytes, UInt64(input.html.count), options.limits.maxHTMLBytes) - try pdfRequire(.pages, 1, options.limits.maxPages) - try pdfRequire(.pdfBytes, 1, min(options.limits.maxPDFBytes, input.representationLimit)) - try pdfRequire(.preparedBytes, input.bytes - input.previousPDFBytes, - min(options.limits.maxPreparedBytes, input.preparedLimit)) - try meter.retain(input.bytes) - let inputMetadata = UInt64(document.filenameStem.utf8.count) + 16 + UInt64(document.availableFormats.count) * 48 - try meter.retain(inputMetadata) - for _ in input.html { try work(); try meter.charge(.inputBytes); try meter.retain(1) } - let html = String(decoding: input.html, as: UTF8.self) - let width = floor(options.paper.width - 2 * options.marginPoints) - try advance(.loading); try meter.work(8) - let backend = factory(); self.backend = backend - try await backend.load(html) - try advance(.layout); try work() - let geometry = try await backend.layout(width: width) - try check() - guard geometry.width.isFinite, geometry.height.isFinite, geometry.lineHeight.isFinite, - abs(geometry.width - width) < 0.1, geometry.height > 0, - abs(geometry.lineHeight - 16) < 0.01 else { - throw JSONPDFFailure.layoutFailed + private func check() throws { + guard !terminal, !cancellation.isCancelled else { throw CancellationError() } + try meter.checkpoint() + if ContinuousClock.now >= deadline { throw JSONPDFFailure.timedOut } } - let maximumHeight = options.layout == .continuous - ? min(options.limits.maxContentHeightPoints, UInt64(floor(14_400 - 2 * options.marginPoints))) - : options.limits.maxContentHeightPoints - guard geometry.height < Double(UInt64.max) else { throw JSONPDFFailure.counterOverflow(.contentHeightPoints) } - try pdfRequire(.contentHeightPoints, UInt64(ceil(geometry.height)), maximumHeight) - let chunk = options.layout == .continuous ? geometry.height - : floor((options.paper.height - 2 * options.marginPoints) / 16) * 16 - let pageCount = UInt64(ceil(geometry.height / chunk)) - try pdfRequire(.pages, pageCount, options.limits.maxPages) - let height = options.layout == .continuous ? geometry.height + 2 * options.marginPoints : options.paper.height - let compositor = try JSONPDFCompositor(width: options.paper.width, height: height, margin: options.marginPoints) - self.compositor = compositor - var captured: UInt64 = 0 - var actualPages: UInt64 = 0 - var textReservation: UInt64 = 0 - if options.layout == .paginated { - // Fixed client script and conversion loops are conservatively admitted - // under this same meter, before native extraction/attributed layout. - try meter.work(UInt64(input.html.count)) - try advance(.extraction) - let info = try await backend.textInfo() + private func advance(_ stage: JSONPDFStage) throws { + try check() + observer?(stage) try check() - guard info.count > 0, UInt64(info.count) <= UInt64(input.html.count) else { throw JSONPDFFailure.layoutFailed } - var runs: [JSONPDFTextRun] = [] - var textBytes: UInt64 = 0 - for index in 0..= 1_024 { + ownedSinceCheck = 0 try check() - textBytes = try pdfAdd(.htmlBytes, textBytes, UInt64(run.text.utf8.count), UInt64(input.html.count)) - try meter.retain(96); textReservation += 96 - for _ in run.text.utf16 { try work(); try meter.retain(4); textReservation += 4 } - // Admit source/attributed reservations before owned array growth. - runs.append(run) } - try await compositor.configure(.init(runs: runs, tabWidth: info.tabWidth)) - var complete = false - var contentHeight: UInt64 = 0 - while !complete { - try advance(.capture); try work() - actualPages = try pdfAdd(.pages, actualPages, 1, options.limits.maxPages) - let page = try await compositor.nextTextPage(contentWidth: width, contentHeight: chunk) + } + private func retainBytes(_ bytes: Data, output: Bool) throws { + for _ in bytes { + try work() + if output { try meter.charge(.outputBytes) } + try meter.retain(1) + } + try check() + } + private func prepare() async throws -> JSONPDFPreparationReport { + try advance(.input) + guard let document else { throw CancellationError() } + try meter.work(UInt64(document.availableFormats.count)) + let input = try document.pdfInput() + try pdfRequire(.htmlBytes, UInt64(input.html.count), options.limits.maxHTMLBytes) + try pdfRequire(.pages, 1, options.limits.maxPages) + try pdfRequire(.pdfBytes, 1, min(options.limits.maxPDFBytes, input.representationLimit)) + try pdfRequire( + .preparedBytes, input.bytes - input.previousPDFBytes, + min(options.limits.maxPreparedBytes, input.preparedLimit)) + try meter.retain(input.bytes) + let inputMetadata = + UInt64(document.filenameStem.utf8.count) + 16 + UInt64( + document.availableFormats.count) * 48 + try meter.retain(inputMetadata) + for _ in input.html { + try work() + try meter.charge(.inputBytes) + try meter.retain(1) + } + let html = String(decoding: input.html, as: UTF8.self) + let width = floor(options.paper.width - 2 * options.marginPoints) + try advance(.loading) + try meter.work(8) + let backend = factory() + self.backend = backend + try await backend.load(html) + try advance(.layout) + try meter.work(UInt64(input.html.count)) + try work() + let geometry = try await backend.layout(width: width) + try check() + guard geometry.width.isFinite, geometry.height.isFinite, geometry.lineHeight.isFinite, + abs(geometry.width - width) < 0.1, geometry.height > 0, + abs(geometry.lineHeight - 16) < 0.01 + else { + throw JSONPDFFailure.layoutFailed + } + let maximumHeight = + options.layout == .continuous + ? min( + options.limits.maxContentHeightPoints, + UInt64(floor(14_400 - 2 * options.marginPoints))) + : options.limits.maxContentHeightPoints + guard geometry.height < Double(UInt64.max) else { + throw JSONPDFFailure.counterOverflow(.contentHeightPoints) + } + try pdfRequire(.contentHeightPoints, UInt64(ceil(geometry.height)), maximumHeight) + let chunk = + options.layout == .continuous + ? geometry.height + : floor((options.paper.height - 2 * options.marginPoints) / 16) * 16 + let pageCount = UInt64(ceil(geometry.height / chunk)) + try pdfRequire(.pages, pageCount, options.limits.maxPages) + let height = + options.layout == .continuous + ? geometry.height + 2 * options.marginPoints : options.paper.height + let compositor = try JSONPDFCompositor( + width: options.paper.width, height: height, margin: options.marginPoints) + self.compositor = compositor + var captured: UInt64 = 0 + var actualPages: UInt64 = 0 + var textReservation: UInt64 = 0 + if options.layout == .paginated && document.htmlPolicy?.layout != .structured { + // Fixed client script and conversion loops are conservatively admitted + // under this same meter, before native extraction/attributed layout. + try meter.work(UInt64(input.html.count)) + try advance(.extraction) + let info = try await backend.textInfo() try check() - contentHeight = try pdfAdd(.contentHeightPoints, contentHeight, page.heightPoints, options.limits.maxContentHeightPoints) - captured = try pdfAdd(.captureBytes, captured, UInt64(page.data.count), options.limits.maxCaptureBytes) - try retainBytes(page.data, output: true) - try advance(.composition); try work() - try await compositor.append(page.data, region: .init(x: 0, y: 0, width: width, height: chunk)) - try check(); try meter.release(UInt64(page.data.count)) - complete = page.isComplete + guard info.count > 0, UInt64(info.count) <= UInt64(input.html.count) else { + throw JSONPDFFailure.layoutFailed + } + var runs: [JSONPDFTextRun] = [] + var textBytes: UInt64 = 0 + for index in 0..= 0, + span.bottom >= span.top, span.bottom <= geometry.height + 1 + else { throw JSONPDFFailure.layoutFailed } + guard UInt64(spans.count) < UInt64(input.html.count) else { + throw JSONPDFFailure.layoutFailed + } + try meter.retain(32) + textReservation += 32 + spans.append(span) + } + } + } + try work() + let captureRows = try await backend.captureRowCount() + guard captureRows >= 0, UInt64(captureRows) <= UInt64(input.html.count) else { + throw JSONPDFFailure.layoutFailed + } + var offset = 0.0 + while offset < geometry.height { + try advance(.capture) + try work() + var end = min(offset + chunk, geometry.height) + if options.layout == .paginated && end < geometry.height { + // Cut through actual whitespace between line/media bounds. A tall + // unbreakable fragment is an explicit layout failure, never a syntax fallback. + var changed = true + while changed { + changed = false + for span in spans { + try work() + if span.top < end && span.bottom > end { + end = floor(span.top) + changed = true + } + } + } + guard end > offset else { throw JSONPDFFailure.layoutFailed } + } + let region = JSONPDFRectangle( + x: 0, y: offset, width: width, height: end - offset) + for index in 0..")) + if flags.first == "--string-preview" { #expect(html.contains("first 0 decoded scalars")) } + if flags.first == "--layout" { + #expect(!html.contains("")) + } + if flags.first == "--full-values" { #expect(!html.contains("String preview ·")) } + #expect(context.committedResults == 1) + } + let prior = try Data(contentsOf: output) + var limits = CLILimits() + limits.core[.workUnits] = 1_000 + var failing = try CLIJobContext(limits: limits) + let args = try CLIArguments( + ["render", input.path, "--output", output.path, "--overwrite"], context: &failing) + #expect(throws: (any Error).self) { _ = try CLIEngine.execute(args, context: &failing) } + #expect(try Data(contentsOf: output) == prior && failing.committedResults == 0) + #expect( + try FileManager.default.contentsOfDirectory(atPath: root.path).sorted() == [ + "input.json", "output.html", + ]) +} diff --git a/Tests/JSONHTMLTests/ExportTests.swift b/Tests/JSONHTMLTests/ExportTests.swift index b177baf..8ee5ac8 100644 --- a/Tests/JSONHTMLTests/ExportTests.swift +++ b/Tests/JSONHTMLTests/ExportTests.swift @@ -50,10 +50,11 @@ import UniformTypeIdentifiers let item = try JSONExportDocument.prepare(.string("&"), options: .init(title: "&")) let html = String(decoding: try item.data(for: .html), as: UTF8.self) - #expect(html.contains("class=\"light tabs-4\"")) + #expect(html.contains("class=\"rich light tabs-4\"")) #expect(html.contains("</style><script>boom</script>&")) #expect(html.contains("script-src 'none'") && html.contains("default-src 'none'")) - for forbidden in ["x)", + "expected": "literal_or_inert_reference", + "reason": "No unsafe navigation, remote image request or SVG input", + "status": "not_run" + }, + { + "id": "IN26", + "source": "[x](file:///private/file)", + "expected": "literal_or_inert_reference", + "reason": "No unsafe navigation, remote image request or SVG input", + "status": "not_run" + }, + { + "id": "IN27", + "source": "[x](//example.invalid/)", + "expected": "literal_or_inert_reference", + "reason": "No unsafe navigation, remote image request or SVG input", + "status": "not_run" + }, + { + "id": "IN28", + "source": "[x](https://user:secret@example.invalid/)", + "expected": "literal_or_inert_reference", + "reason": "No unsafe navigation, remote image request or SVG input", + "status": "not_run" + }, + { + "id": "IN29", + "source": "[x](https://example.invalid/\u0000x)", + "expected": "literal_or_inert_reference", + "reason": "No unsafe navigation, remote image request or SVG input", + "status": "not_run" + }, + { + "id": "IN30", + "source": "[x](javascript:alert(1))", + "expected": "literal_or_inert_reference", + "reason": "No unsafe navigation, remote image request or SVG input", + "status": "not_run" + }, + { + "id": "IN31", + "source": "![photo](https://example.invalid/photo.png)", + "expected": "literal_or_inert_reference", + "reason": "No unsafe navigation, remote image request or SVG input", + "status": "not_run" + }, + { + "id": "IN32", + "source": "![svg](data:image/svg+xml;base64,PHN2Zy8+)", + "expected": "literal_or_inert_reference", + "reason": "No unsafe navigation, remote image request or SVG input", + "status": "not_run" + } + ] +} diff --git a/Tests/JSONHTMLTests/Fixtures/Enrichment/math-cases.json b/Tests/JSONHTMLTests/Fixtures/Enrichment/math-cases.json new file mode 100644 index 0000000..44bc11c --- /dev/null +++ b/Tests/JSONHTMLTests/Fixtures/Enrichment/math-cases.json @@ -0,0 +1,411 @@ +{ + "version": 1, + "scope": "Hand-authored structural gold cases; no native parser has run", + "cases": [ + { + "id": "MT01", + "source": "x", + "expected": "converted", + "mathml": "x", + "fallbackSource": null, + "reason": "Hand-authored expected tree; single-child groups may be unwrapped only by a documented canonicalization with equivalent tree semantics", + "status": "not_run" + }, + { + "id": "MT02", + "source": "2", + "expected": "converted", + "mathml": "2", + "fallbackSource": null, + "reason": "Hand-authored expected tree; single-child groups may be unwrapped only by a documented canonicalization with equivalent tree semantics", + "status": "not_run" + }, + { + "id": "MT03", + "source": "123.45", + "expected": "converted", + "mathml": "123.45", + "fallbackSource": null, + "reason": "Hand-authored expected tree; single-child groups may be unwrapped only by a documented canonicalization with equivalent tree semantics", + "status": "not_run" + }, + { + "id": "MT04", + "source": "x^2", + "expected": "converted", + "mathml": "x2", + "fallbackSource": null, + "reason": "Hand-authored expected tree; single-child groups may be unwrapped only by a documented canonicalization with equivalent tree semantics", + "status": "not_run" + }, + { + "id": "MT05", + "source": "x_i", + "expected": "converted", + "mathml": "xi", + "fallbackSource": null, + "reason": "Hand-authored expected tree; single-child groups may be unwrapped only by a documented canonicalization with equivalent tree semantics", + "status": "not_run" + }, + { + "id": "MT06", + "source": "x_i^2", + "expected": "converted", + "mathml": "xi2", + "fallbackSource": null, + "reason": "Hand-authored expected tree; single-child groups may be unwrapped only by a documented canonicalization with equivalent tree semantics", + "status": "not_run" + }, + { + "id": "MT07", + "source": "x^2_i", + "expected": "converted", + "mathml": "xi2", + "fallbackSource": null, + "reason": "Hand-authored expected tree; single-child groups may be unwrapped only by a documented canonicalization with equivalent tree semantics", + "status": "not_run" + }, + { + "id": "MT08", + "source": "x^{12}", + "expected": "converted", + "mathml": "x12", + "fallbackSource": null, + "reason": "Hand-authored expected tree; single-child groups may be unwrapped only by a documented canonicalization with equivalent tree semantics", + "status": "not_run" + }, + { + "id": "MT09", + "source": "x_{i+1}", + "expected": "converted", + "mathml": "xi+1", + "fallbackSource": null, + "reason": "Hand-authored expected tree; single-child groups may be unwrapped only by a documented canonicalization with equivalent tree semantics", + "status": "not_run" + }, + { + "id": "MT10", + "source": "\\frac{a}{b}", + "expected": "converted", + "mathml": "ab", + "fallbackSource": null, + "reason": "Hand-authored expected tree; single-child groups may be unwrapped only by a documented canonicalization with equivalent tree semantics", + "status": "not_run" + }, + { + "id": "MT11", + "source": "\\sqrt{x}", + "expected": "converted", + "mathml": "x", + "fallbackSource": null, + "reason": "Hand-authored expected tree; single-child groups may be unwrapped only by a documented canonicalization with equivalent tree semantics", + "status": "not_run" + }, + { + "id": "MT12", + "source": "\\alpha", + "expected": "converted", + "mathml": "α", + "fallbackSource": null, + "reason": "Hand-authored expected tree; single-child groups may be unwrapped only by a documented canonicalization with equivalent tree semantics", + "status": "not_run" + }, + { + "id": "MT13", + "source": "\\pi", + "expected": "converted", + "mathml": "π", + "fallbackSource": null, + "reason": "Hand-authored expected tree; single-child groups may be unwrapped only by a documented canonicalization with equivalent tree semantics", + "status": "not_run" + }, + { + "id": "MT14", + "source": "\\Delta", + "expected": "converted", + "mathml": "Δ", + "fallbackSource": null, + "reason": "Hand-authored expected tree; single-child groups may be unwrapped only by a documented canonicalization with equivalent tree semantics", + "status": "not_run" + }, + { + "id": "MT15", + "source": "a\\times b", + "expected": "converted", + "mathml": "a×b", + "fallbackSource": null, + "reason": "Hand-authored expected tree; single-child groups may be unwrapped only by a documented canonicalization with equivalent tree semantics", + "status": "not_run" + }, + { + "id": "MT16", + "source": "x\\le y", + "expected": "converted", + "mathml": "x≤y", + "fallbackSource": null, + "reason": "Hand-authored expected tree; single-child groups may be unwrapped only by a documented canonicalization with equivalent tree semantics", + "status": "not_run" + }, + { + "id": "MT17", + "source": "\\frac{x}{0}", + "expected": "converted", + "mathml": "x0", + "fallbackSource": null, + "reason": "Hand-authored expected tree; single-child groups may be unwrapped only by a documented canonicalization with equivalent tree semantics", + "status": "not_run" + }, + { + "id": "MT18", + "source": "9007199254740993", + "expected": "converted", + "mathml": "9007199254740993", + "fallbackSource": null, + "reason": "Hand-authored expected tree; single-child groups may be unwrapped only by a documented canonicalization with equivalent tree semantics", + "status": "not_run" + }, + { + "id": "MT19", + "source": "x^2+\\unknown{a}", + "expected": "literal", + "mathml": null, + "fallbackSource": "x^2+\\unknown{a}", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + }, + { + "id": "MT20", + "source": "\\frac{a}", + "expected": "literal", + "mathml": null, + "fallbackSource": "\\frac{a}", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + }, + { + "id": "MT21", + "source": "\\frac{}{b}", + "expected": "literal", + "mathml": null, + "fallbackSource": "\\frac{}{b}", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + }, + { + "id": "MT22", + "source": "\\sqrt{}", + "expected": "literal", + "mathml": null, + "fallbackSource": "\\sqrt{}", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + }, + { + "id": "MT23", + "source": "x^", + "expected": "literal", + "mathml": null, + "fallbackSource": "x^", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + }, + { + "id": "MT24", + "source": "x_", + "expected": "literal", + "mathml": null, + "fallbackSource": "x_", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + }, + { + "id": "MT25", + "source": "x^12", + "expected": "literal", + "mathml": null, + "fallbackSource": "x^12", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + }, + { + "id": "MT26", + "source": "x_ab", + "expected": "literal", + "mathml": null, + "fallbackSource": "x_ab", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + }, + { + "id": "MT27", + "source": "x_i_j", + "expected": "literal", + "mathml": null, + "fallbackSource": "x_i_j", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + }, + { + "id": "MT28", + "source": "x^2^3", + "expected": "literal", + "mathml": null, + "fallbackSource": "x^2^3", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + }, + { + "id": "MT29", + "source": "{x", + "expected": "literal", + "mathml": null, + "fallbackSource": "{x", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + }, + { + "id": "MT30", + "source": "x}", + "expected": "literal", + "mathml": null, + "fallbackSource": "x}", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + }, + { + "id": "MT31", + "source": "\\alphabet", + "expected": "literal", + "mathml": null, + "fallbackSource": "\\alphabet", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + }, + { + "id": "MT32", + "source": "\\sum_{i=1}^{n}i", + "expected": "literal", + "mathml": null, + "fallbackSource": "\\sum_{i=1}^{n}i", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + }, + { + "id": "MT33", + "source": "\\int x", + "expected": "literal", + "mathml": null, + "fallbackSource": "\\int x", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + }, + { + "id": "MT34", + "source": "\\sin{x}", + "expected": "literal", + "mathml": null, + "fallbackSource": "\\sin{x}", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + }, + { + "id": "MT35", + "source": "\\text{hello}", + "expected": "literal", + "mathml": null, + "fallbackSource": "\\text{hello}", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + }, + { + "id": "MT36", + "source": "\\color{red}{x}", + "expected": "literal", + "mathml": null, + "fallbackSource": "\\color{red}{x}", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + }, + { + "id": "MT37", + "source": "\\href{javascript:alert(1)}{x}", + "expected": "literal", + "mathml": null, + "fallbackSource": "\\href{javascript:alert(1)}{x}", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + }, + { + "id": "MT38", + "source": "\\begin{matrix}a\\end{matrix}", + "expected": "literal", + "mathml": null, + "fallbackSource": "\\begin{matrix}a\\end{matrix}", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + }, + { + "id": "MT39", + "source": "\\sqrt[3]{x}", + "expected": "literal", + "mathml": null, + "fallbackSource": "\\sqrt[3]{x}", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + }, + { + "id": "MT40", + "source": "", + "expected": "literal", + "mathml": null, + "fallbackSource": "", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + }, + { + "id": "MT41", + "source": "x\n+y", + "expected": "literal", + "mathml": null, + "fallbackSource": "x\n+y", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + }, + { + "id": "MT42", + "source": "x&y", + "expected": "literal", + "mathml": null, + "fallbackSource": "x&y", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + }, + { + "id": "MT43", + "source": "x^2\\garbage", + "expected": "literal", + "mathml": null, + "fallbackSource": "x^2\\garbage", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + }, + { + "id": "MT44", + "source": "", + "expected": "literal", + "mathml": null, + "fallbackSource": "", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + }, + { + "id": "MT45", + "source": "\\frac{a}{b}#", + "expected": "literal", + "mathml": null, + "fallbackSource": "\\frac{a}{b}#", + "reason": "Unsupported/malformed/ambiguous/trailing syntax; original entire delimited expression must remain literal", + "status": "not_run" + } + ] +} diff --git a/Tests/JSONHTMLTests/Fixtures/Enrichment/media.json b/Tests/JSONHTMLTests/Fixtures/Enrichment/media.json new file mode 100644 index 0000000..d3511c0 --- /dev/null +++ b/Tests/JSONHTMLTests/Fixtures/Enrichment/media.json @@ -0,0 +1,25 @@ +{ + "pngDataURI": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAH0lEQVR42mOwqmn//////wYGBob/Zzpc/jMxoAHCAgArzQlWwCUN4AAAAABJRU5ErkJggg==", + "jpegDataURI": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAAEAAQDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDb0X/kafFX/cD/APUb0iiiivyDOf8AkYVv8R+o5L/yLqH+FH//2Q==", + "declaredBarePNG": "iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAH0lEQVR42mOwqmn//////wYGBob/Zzpc/jMxoAHCAgArzQlWwCUN4AAAAABJRU5ErkJggg==", + "unhintedBarePNG": "iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAH0lEQVR42mOwqmn//////wYGBob/Zzpc/jMxoAHCAgArzQlWwCUN4AAAAABJRU5ErkJggg==", + "mismatch": "data:image/jpeg;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAH0lEQVR42mOwqmn//////wYGBob/Zzpc/jMxoAHCAgArzQlWwCUN4AAAAABJRU5ErkJggg==", + "invalidEncoding": "data:image/png;base64,!!!!", + "noncanonicalPadBits": "data:image/png;base64,Zh==", + "oversizedHeader": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgABhqAAAYagCAIAAAAnMJyfAAAAAElFTkSuQmCC", + "remote": "https://example.invalid/image.png", + "svg": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxzY3JpcHQ+YWxlcnQoMSk8L3NjcmlwdD48L3N2Zz4=", + "inlineSmallPNG": "![sample](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAH0lEQVR42mOwqmn//////wYGBob/Zzpc/jMxoAHCAgArzQlWwCUN4AAAAABJRU5ErkJggg==)", + "declarations": [ + { + "path": [ + { + "key": "declaredBarePNG" + } + ], + "kind": "embeddedImage", + "contentEncoding": "base64", + "contentMediaType": "image/png" + } + ] +} diff --git a/Tests/JSONHTMLTests/Fixtures/Enrichment/oversized-header.png b/Tests/JSONHTMLTests/Fixtures/Enrichment/oversized-header.png new file mode 100644 index 0000000..a9608c6 Binary files /dev/null and b/Tests/JSONHTMLTests/Fixtures/Enrichment/oversized-header.png differ diff --git a/Tests/JSONHTMLTests/Fixtures/Enrichment/sample.jpg b/Tests/JSONHTMLTests/Fixtures/Enrichment/sample.jpg new file mode 100644 index 0000000..24a7434 Binary files /dev/null and b/Tests/JSONHTMLTests/Fixtures/Enrichment/sample.jpg differ diff --git a/Tests/JSONHTMLTests/Fixtures/Enrichment/sample.png b/Tests/JSONHTMLTests/Fixtures/Enrichment/sample.png new file mode 100644 index 0000000..c0615b5 Binary files /dev/null and b/Tests/JSONHTMLTests/Fixtures/Enrichment/sample.png differ diff --git a/Tests/JSONHTMLTests/Fixtures/Enrichment/strings.json b/Tests/JSONHTMLTests/Fixtures/Enrichment/strings.json new file mode 100644 index 0000000..7589890 --- /dev/null +++ b/Tests/JSONHTMLTests/Fixtures/Enrichment/strings.json @@ -0,0 +1,17 @@ +{ + "empty": "", + "ascii511": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "ascii512": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "ascii513": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "astral511": "😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀", + "astral512": "😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀", + "astral513": "😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀", + "combining": "á́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́", + "controlExpansion": "\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000", + "bidi": "‮abc‬", + "cutoffMath": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa $x^{2}$", + "cutoffLink": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa [x](https://example.invalid/)", + "blockDocument": "# Heading\n\n**bold**\n```swift\n$x^2$\n```\n\n|a|b|", + "exactLargeIntegerText": "9007199254740993", + "fullSuffix": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaTAIL_SENTINEL" +} diff --git a/Tests/JSONHTMLTests/FormattedRendererTests.swift b/Tests/JSONHTMLTests/FormattedRendererTests.swift index a8cfdea..3f0b135 100644 --- a/Tests/JSONHTMLTests/FormattedRendererTests.swift +++ b/Tests/JSONHTMLTests/FormattedRendererTests.swift @@ -4,6 +4,12 @@ import Testing import JSON @testable import JSONHTML +// The revision changes direct defaults; these unchanged exact-syntax invariants +// explicitly select the original formatted syntax contract. +private func syntaxRenderer(options: JSONHTMLRenderingOptions = .init()) -> JSONHTMLRenderer { + var explicit = options; explicit.layout = .syntax; explicit.presentation = .formatted + return JSONHTMLRenderer(options: explicit) +} // Independent text extraction: fixed span markup is removed before decoding entities. private func body(_ html: String) throws -> [UInt8] { let start = try #require(html.range(of: "
      "))
      @@ -22,7 +28,7 @@ private func body(_ html: String) throws -> [UInt8] {
       @Test func formattedHTMLHandlesSixRootsAndEmptyContainers() throws {
           for text in ["null", "false", "9007199254740993", #""é\u0000""#, "[true,null]", #"{"a":1}"#, "[]", "{}"] {
               let root = try JSONParser().parse(text)
      -        let html = try JSONHTMLRenderer().renderString(root)
      +        let html = try syntaxRenderer().renderString(root)
               #expect(html.hasPrefix(""))
               #expect(html.hasSuffix("\n"))
               #expect(!html.contains(" [UInt8] {
       @Test func formattedHTMLPreservesExactLexemesScalarKeysAndInsertionOrder() throws {
           let text = #"{"é":9007199254740993,"é":-0.00e+2,"":1.2300E-7,"0":"é","__proto__":true,"constructor":null}"#
           let root = try JSONParser().parse(text)
      -    let actual = try body(JSONHTMLRenderer().renderString(root))
      +    let actual = try body(syntaxRenderer().renderString(root))
           let expected = "{\n  \"é\": 9007199254740993,\n  \"e\u{301}\": -0.00e+2,\n  \"\": 1.2300E-7,\n  \"0\": \"e\u{301}\",\n  \"__proto__\": true,\n  \"constructor\": null\n}"
           #expect(actual == Array(expected.utf8)) // Swift String equality alone normalizes scalars.
      -    #expect(try body(JSONHTMLRenderer().renderString(.number(42))) == Array("42".utf8))
      +    #expect(try body(syntaxRenderer().renderString(.number(42))) == Array("42".utf8))
       }
       
       @Test func formattedHTMLKeepsPayloadAndTitleInert() throws {
           let payload = " " & < > \" ' \u{0}\n\t😀"
           let options = JSONHTMLRenderingOptions(title: "&\"'")
      -    let html = try JSONHTMLRenderer(options: options).renderString(.string(payload))
      +    let html = try syntaxRenderer(options: options).renderString(.string(payload))
           #expect(!html.contains("") }
      -    return try JSONExportDocument.prepare(.array(values + [.string("LAST-CONTENT-SENTINEL")]))
      +    return try syntaxPDFInput(.array(values + [.string("LAST-CONTENT-SENTINEL")]))
       }
       @Suite(.serialized) struct ApplePDFTests {
       @Test @MainActor func applePDFPaginatedA4AndLetterHaveCompleteSelectableRegions() async throws {
      @@ -97,7 +104,7 @@ import PDFKit
           #expect(try #require(pdf.page(at: 0)).bounds(for: .mediaBox).height > 2_000)
           #expect(pdf.string?.contains("LAST-CONTENT-SENTINEL") == true)
           for source in ["null", "true", "9007199254740993", #""é😀{}\\""#, "[]", "{}"] {
      -        let item = try JSONExportDocument.prepare(JSONParser().parse(source))
      +        let item = try syntaxPDFInput(JSONParser().parse(source))
               let result = try await item.preparingPDF()
               let document = try #require(PDFDocument(data: result.data(for: .pdf)))
               #expect(document.pageCount == 1 && !(document.string ?? "").isEmpty)
      @@ -107,7 +114,7 @@ import PDFKit
       @Test @MainActor func applePDFWrapsWideContentAndHonorsZeroAndWideMargins() async throws {
           pdfBootstrap()
           let root: JSON = .string("FIRST-SENTINEL " + String(repeating: "https://example.test/long-key-😀-e\u{301}/", count: 300) + " LAST-CONTENT-SENTINEL")
      -    let item = try JSONExportDocument.prepare(root)
      +    let item = try syntaxPDFInput(root)
           for margin in [0.0, 144.0] {
               let report = try await item.preparingPDFWithReport(options: .init(marginPoints: margin))
               let pdf = try #require(PDFDocument(data: report.result.data(for: .pdf)))
      @@ -121,7 +128,7 @@ import PDFKit
           pdfBootstrap()
           let item = try pdfFixture()
           let originalJSON = try item.data(for: .json)
      -    let small = try JSONExportDocument.prepare(.null)
      +    let small = try syntaxPDFInput(.null)
           let accepted = try await small.preparingPDFWithReport()
           let inclusive = [JSONPDFLimits(maxHTMLBytes: accepted.inputHTMLBytes),
               .init(maxCaptureBytes: accepted.capturedPDFBytes), .init(maxPDFBytes: accepted.outputPDFBytes),
      @@ -160,7 +167,7 @@ import PDFKit
       
       @Test @MainActor func applePDFOwnedBudgetsAreCumulativeAndInclusive() async throws {
           pdfBootstrap()
      -    let item = try JSONExportDocument.prepare(.string("é e\u{301} 😀 exact 9007199254740993"))
      +    let item = try syntaxPDFInput(.string("é e\u{301} 😀 exact 9007199254740993"))
           let baseline = try await item.preparingPDFWithReport()
           #expect(baseline.operation[.workUnits] > baseline.inputHTMLBytes + baseline.outputPDFBytes)
           let resultBytes = baseline.existingPreparedBytes + baseline.outputPDFBytes
      @@ -179,14 +186,14 @@ import PDFKit
       
       @Test @MainActor func applePDFHiddenHTMLAndReplacementPreserveImmutablePolicy() async throws {
           pdfBootstrap()
      -    let original = try JSONExportDocument.prepare(.null).selecting([.json])
      +    let original = try syntaxPDFInput(.null).selecting([.json])
           let first = try await original.preparingPDFWithReport()
           #expect(first.result.advertisedFormats == [.json, .pdf] && first.result.availableFormats.count == 5)
           let second = try await first.result.preparingPDFWithReport()
           #expect(second.result.availableFormats.count == 5 && second.existingPreparedBytes == first.existingPreparedBytes + first.outputPDFBytes)
           #expect(try second.result.data(for: .json) == original.data(for: .json))
           let sum = try original.pdfInput().bytes
      -    let tight = try JSONExportDocument.prepare(.null, options: .init(limits: .init(maxPreparedBytes: sum)))
      +    let tight = try syntaxPDFInput(.null, options: .init(limits: .init(maxPreparedBytes: sum)))
           do { _ = try await tight.preparingPDF(); Issue.record("Original aggregate cap was widened") }
           catch { #expect((error as? JSONPDFFailure) != nil) }
           #expect(!tight.availableFormats.contains(.pdf))
      @@ -194,7 +201,7 @@ import PDFKit
       
       @Test @MainActor func applePDFFailureAndImmediateDeadlineNeverAdvertiseAnArtifact() async throws {
           pdfBootstrap()
      -    let item = try JSONExportDocument.prepare(.null)
      +    let item = try syntaxPDFInput(.null)
           do { _ = try await item.preparingPDF(options: .init(timeoutMilliseconds: 0)); Issue.record("Expired deadline accepted PDF") }
           catch { #expect(error as? JSONPDFFailure == .timedOut) }
           // Real iOS emoji fallback produced height 162 with nominal 16-point lines.
      @@ -212,7 +219,7 @@ import PDFKit
       
       @Test @MainActor func applePDFTransferOffersOnlyPreparedSelectedBytes() async throws {
           pdfBootstrap()
      -    let item = try JSONExportDocument.prepare(.null)
      +    let item = try syntaxPDFInput(.null)
           let report = try await item.preparingPDFWithReport()
           #if canImport(CoreTransferable) && canImport(UniformTypeIdentifiers)
           let selected = try report.result.selecting([.pdf])
      @@ -253,7 +260,7 @@ import PDFKit
       }
       @Test @MainActor func applePDFTimeoutReturnsWithoutWaitingForUncooperativeCapture() async throws {
           pdfBootstrap()
      -    let item = try JSONExportDocument.prepare(.null)
      +    let item = try syntaxPDFInput(.null)
           for stage in [JSONPDFStage.loading, .layout, .capture] {
           let backend = SuspendedPDFBackend(ready: {}); backend.waitAt = stage
           let start = ContinuousClock.now
      @@ -289,7 +296,7 @@ import PDFKit
       }
       #else
       @Test @MainActor func exportPDFUnavailableBackendRemainsUnadvertised() async throws {
      -    let item = try JSONExportDocument.prepare(.null)
      +    let item = try syntaxPDFInput(.null)
           do { _ = try await item.preparingPDF(); Issue.record("Unsupported platform advertised PDF") }
           catch { #expect(error as? JSONPDFFailure == .backendUnavailable) }
           #expect(!item.availableFormats.contains(.pdf))
      diff --git a/Tests/JSONHTMLTests/StructuredPDFTests.swift b/Tests/JSONHTMLTests/StructuredPDFTests.swift
      new file mode 100644
      index 0000000..abc3748
      --- /dev/null
      +++ b/Tests/JSONHTMLTests/StructuredPDFTests.swift
      @@ -0,0 +1,99 @@
      +// SPDX-License-Identifier: Apache-2.0
      +import Foundation
      +import JSON
      +import Testing
      +
      +@testable import JSONHTML
      +
      +#if canImport(PDFKit) && canImport(WebKit)
      +    import PDFKit
      +    #if canImport(AppKit)
      +        import AppKit
      +    #endif
      +    @Suite(.serialized) struct StructuredApplePDFTests {
      +        @Test @MainActor func structuredPDFPrintCaptureKeepsMediaMathFullStringsAndTail()
      +            async throws
      +        {
      +            #if canImport(AppKit)
      +                _ = NSApplication.shared
      +            #endif
      +            let url = try #require(
      +                Bundle.module.url(
      +                    forResource: "media", withExtension: "json", subdirectory: "Fixtures/Enrichment"
      +                ))
      +            guard case .object(let media) = try JSONParser().parse(Array(Data(contentsOf: url)))
      +            else {
      +                Issue.record("Missing media fixture")
      +                return
      +            }
      +            let root = JSON.array([
      +                .object(
      +                    try JSONObject(members: [
      +                        JSONMember("image", value: media[JSONKey("pngDataURI")]!),
      +                        JSONMember("math", value: .string("$x_i^2$")),
      +                        JSONMember("color", value: .string("#369c")),
      +                    ])),
      +                .string(
      +                    "FIRST " + String(repeating: "long text ", count: 180)
      +                        + " LAST-CONTENT-SENTINEL"),
      +            ])
      +            let original = try JSONExportDocument.prepare(root)
      +            let hashInput = try original.data(for: .html)
      +            for layout in [JSONPDFLayout.paginated, .continuous] {
      +                let report = try await original.preparingPDFWithReport(
      +                    options: .init(layout: layout))
      +                let pdf = try #require(PDFDocument(data: report.result.data(for: .pdf)))
      +                #expect(report.htmlLayout == .structured && report.valueProfile == .complete)
      +                #expect(report.backend.contains("structured regions"))
      +                #expect(report.mediaReadiness.contains("decoded"))
      +                #expect(pdf.pageCount > 0 && UInt64(pdf.pageCount) == report.pageCount)
      +                let extracted = pdf.string ?? ""
      +                let compact = extracted.filter { !$0.isWhitespace }
      +                #expect(compact.contains("LAST-CONTENT-SENTINEL"))  // Native PDF text extraction inserts wrap newlines, including after hyphens.
      +                #expect(compact.components(separatedBy: "longtext").count - 1 == 180)
      +                let evidence = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
      +                    .appendingPathComponent(".build/structured-pdf-" + layout.rawValue + ".pdf")
      +                try report.result.data(for: .pdf).write(to: evidence)
      +                #expect(try report.result.data(for: .html) == hashInput)
      +                #expect(report.operation[.nodes] == 0)
      +            }
      +        }
      +        @Test @MainActor func structuredPDFCapsAndFailedRasterDecodeInstallNothing() async throws {
      +            #if canImport(AppKit)
      +                _ = NSApplication.shared
      +            #endif
      +            let small = try JSONExportDocument.prepare(.null)
      +            let measured = try await small.preparingPDFWithReport()
      +            #expect(measured.htmlLayout == .structured && measured.pageCount == 1)
      +            let inclusive = JSONPDFLimits(
      +                maxHTMLBytes: measured.inputHTMLBytes, maxCaptureBytes: measured.capturedPDFBytes,
      +                maxPDFBytes: measured.outputPDFBytes)
      +            #expect(
      +                try await small.preparingPDFWithReport(options: .init(limits: inclusive)).pageCount
      +                    == 1)
      +            do {
      +                _ = try await small.preparingPDF(options: .init(limits: .init(maxPages: 0)))
      +                Issue.record("Expected checked cap")
      +            } catch { #expect(error is JSONPDFFailure) }
      +            #expect(!small.availableFormats.contains(.pdf))
      +            let url = try #require(
      +                Bundle.module.url(
      +                    forResource: "sample", withExtension: "jpg", subdirectory: "Fixtures/Enrichment"
      +                ))
      +            let jpeg = Array(try Data(contentsOf: url))
      +            let sof = try #require(
      +                (0..<(jpeg.count - 1)).first(where: { jpeg[$0] == 255 && jpeg[$0 + 1] == 192 }))
      +            let length = Int(jpeg[sof + 2]) * 256 + Int(jpeg[sof + 3])
      +            let damaged = Data(jpeg[..<(sof + 2 + length)] + [255, 217])  // Geometry exists; SOS/entropy data is deliberately absent.
      +            let undecodable = try JSONExportDocument.prepare(
      +                .string("data:image/jpeg;base64," + damaged.base64EncodedString()))
      +            #expect(
      +                String(decoding: try undecodable.data(for: .html), as: UTF8.self).contains(": Decodable { let cases: [T] }
      +private func previewFixtureURL(_ name: String, _ extensionName: String = "json") throws -> URL {
      +    // WASI Foundation's Bundle named-resource search truncates some extensions.
      +    // Use the exact pinned path under the generated SwiftPM resource directory.
      +    let root = try #require(Bundle.module.resourceURL)
      +    return URL(fileURLWithPath: root.path + "/Fixtures/Enrichment/" + name + "." + extensionName)
      +}
      +private func gold(_ name: String, _: T.Type) throws -> T {
      +    return try JSONDecoder().decode(T.self, from: Data(contentsOf: previewFixtureURL(name)))
      +}
      +private let mathGold = try! gold("math-cases", GoldFile.self).cases
      +private let inlineGold = try! gold("inline-cases", GoldFile.self).cases
      +private let base64Gold = try! gold("base64-cases", GoldFile.self).cases
      +private func inlineBody(_ source: String, options: JSONHTMLRenderingOptions = .init()) throws -> (
      +    String, Bool
      +) {
      +    var meter = try JSONWorkMeter(limits: options.limits.core, domain: .writing)
      +    var ledger = JSONHTMLMediaLedger()
      +    let plan = try JSONHTMLInlineScanner.scan(
      +        source, options: options, ledger: &ledger, meter: &meter)
      +    var out = JSONHTMLDocumentBuffer(limits: options.limits)
      +    try plan.emit(output: &out, meter: &meter)
      +    try meter.release(plan.reservation)
      +    #expect(meter.used(.retainedBytes) == UInt64(out.bytes.count))
      +    return (String(decoding: out.bytes, as: UTF8.self), plan.formatted)
      +}
      +// Canonicalization approved by the gold fixture: single-child groups are unwrapped.
      +private func unwrapSingleRows(_ source: String) -> String {
      +    var s = source
      +    for tag in ["mi", "mn", "mo"] {
      +        let expression = "(<" + tag + ">[^<]*)"
      +        while s.range(of: expression, options: .regularExpression) != nil {
      +            s = s.replacingOccurrences(of: expression, with: "$1", options: .regularExpression)
      +        }
      +    }
      +    return s
      +}
      +@Test(arguments: mathGold) private func structuredMiniMathMatchesIndependentGold(_ item: MathGold)
      +    throws
      +{
      +    var meter = try JSONWorkMeter(limits: .balanced, domain: .writing)
      +    let tree = try JSONHTMLMiniMath.parse(item.source, policy: .init(), meter: &meter)
      +    if item.expected == "converted" {
      +        let tree = try #require(tree, Comment(rawValue: item.id))
      +        var output = JSONHTMLDocumentBuffer(limits: .init())
      +        try tree.emit(output: &output, meter: &meter)
      +        #expect(
      +            String(decoding: output.bytes, as: UTF8.self)
      +                == unwrapSingleRows(try #require(item.mathml)),
      +            Comment(rawValue: item.id))
      +        try meter.release(tree.reservation)
      +        #expect(meter.used(.retainedBytes) == UInt64(output.bytes.count))
      +    } else {
      +        #expect(tree == nil, Comment(rawValue: item.id))
      +        #expect(meter.used(.retainedBytes) == 0)
      +    }
      +    let publicReport = try JSONHTMLRenderer(
      +        options: .init(layout: .structured, presentation: .formatted)
      +    ).renderStringWithReport(.string("\\(" + item.source + "\\)"))
      +    #expect(publicReport.statistics.renderedJSONNodes == 1)
      +    if item.expected == "converted" {
      +        #expect(
      +            publicReport.result.components(separatedBy: unwrapSingleRows(try #require(item.mathml)))
      +                .count
      +                - 1 == 2)
      +    } else {
      +        #expect(!publicReport.result.contains("bold")
      +    case "em": #expect(html == "italic")
      +    case "strong_with_em": #expect(html == "outer inner")
      +    case "em_with_strong": #expect(html == "outer inner")
      +    case "math": #expect(html.contains("")
      +                && !html.contains(""),
      +            Comment(rawValue: item.id))
      +    case "literal_or_inert_reference":
      +        #expect(!html.contains(""))
      +    default:
      +        Issue.record(
      +            Comment(rawValue: "Unrecognized independent fixture verdict: " + item.expected))
      +    }
      +}
      +@Test(arguments: base64Gold) private func structuredCanonicalBase64MatchesPadBitGold(
      +    _ item: Base64Gold
      +) throws {
      +    var meter = try JSONWorkMeter(limits: .balanced, domain: .writing)
      +    do {
      +        let decoded = try JSONHTMLRasterAdmission.canonical(
      +            Array(item.encoded.utf8), maximum: 100, meter: &meter)
      +        #expect(item.valid)
      +        #expect(decoded.map { String(format: "%02x", $0) }.joined() == item.decodedHex)
      +    } catch is JSONHTMLPreviewRejection { #expect(!item.valid) }
      +}
      +@Test func structuredDefaultsAndOldInitializerShapesAreIndependent() throws {
      +    let old:
      +        (String, JSONHTMLTheme, Int, JSONIndentationUnit, Int, JSONHTMLLimits) ->
      +            JSONHTMLRenderingOptions = JSONHTMLRenderingOptions.init
      +    let explicit:
      +        (
      +            String, JSONHTMLTheme, Int, JSONIndentationUnit, Int, JSONHTMLLimits,
      +            JSONHTMLPresentation,
      +            Int, Int?
      +        ) -> JSONHTMLRenderingOptions = JSONHTMLRenderingOptions.init
      +    #expect(old("JSON", .light, 2, .spaces, 4, .init()).layout == .structured)
      +    #expect(
      +        explicit("JSON", .light, 2, .spaces, 4, .init(), .formatted, 2, nil).presentation
      +            == .formatted)
      +    #expect(JSONHTMLRenderingOptions().presentation == .interactive)
      +    for layout in [JSONHTMLLayout.structured, .syntax] {
      +        for presentation in [JSONHTMLPresentation.formatted, .interactive] {
      +            let report = try JSONHTMLRenderer(
      +                options: .init(layout: layout, presentation: presentation)
      +            )
      +            .renderStringWithReport(.array([.null]))
      +            #expect(report.statistics.renderedJSONNodes == 2)
      +            #expect(report.statistics.operation[.nodes] == 2)
      +            #expect(report.result.contains("", presentation: .interactive)),
           ("deep", deep, .init(presentation: .interactive, initiallyExpandedDepth: 0)),
      @@ -28,9 +28,29 @@ let fixtures: [(String, JSON, JSONHTMLRenderingOptions)] = [
           ("null", .null, .init(presentation: .interactive)),
           ("zero-query", exact, zero),
           ("no-outline", exact, .init(presentation: .interactive, outlineDepth: nil)),
      -    ("formatted", exact, .init()),
      +    ("formatted", exact, .init(layout: .syntax, presentation: .formatted)),
           ("annotated", exact, annotations),
           ("annotated-formatted", exact, formattedAnnotations)]
      +let enrichmentURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent("Tests/JSONHTMLTests/Fixtures/Enrichment/media.json")
      +guard case .object(let media) = try JSONParser().parse(Array(Data(contentsOf: enrichmentURL))) else { fatalError("Missing pinned media object") }
      +let previews = try object([
      +    JSONMember("message", value: .string("**Bold** and *italic*, [inert link](https://example.invalid/a_(b)) and $x_i^2$.")),
      +    JSONMember("color", value: .string("#3A7C8780")),
      +    JSONMember("declared", value: media[JSONKey("declaredBarePNG")]!),
      +    JSONMember("image", value: media[JSONKey("pngDataURI")]!),
      +    JSONMember("jpeg", value: media[JSONKey("jpegDataURI")]!),
      +    JSONMember("inlineImage", value: media[JSONKey("inlineSmallPNG")]!),
      +    JSONMember("channels", value: try JSONParser().parse("[1,0,0,0.5]")),
      +    JSONMember("long", value: .string(String(repeating: "😀", count: 513) + "ORIGINAL-TAIL-ONLY")),
      +    JSONMember("unsupportedMath", value: .string("$x^12 + \\unknown$ **outside**")),
      +    JSONMember("records", value: .array([try object([JSONMember("id", value: .number(9007199254740993)), JSONMember("name", value: .string("record"))]), .null]))])
      +var previewOptions = JSONHTMLRenderingOptions(layout: .structured, presentation: .interactive)
      +previewOptions.previewHints = [.init(path: [.key("declared")], kind: .embeddedImage(.png)), .init(path: [.key("channels")], kind: .color(units: .normalized, order: .rgba))]
      +var staticPreview = previewOptions; staticPreview.presentation = .formatted
      +var completePreview = staticPreview; completePreview.valueProfile = .complete
      +fixtures += [("previews", previews, previewOptions), ("static-inspection", previews, staticPreview), ("complete", previews, completePreview),
      +             ("syntax", previews, .init(layout: .syntax, presentation: .interactive)),
      +             ("inspection-long", .string(String(repeating: "😀", count: 513) + "ORIGINAL-TAIL-ONLY"), .init(layout: .structured, presentation: .interactive))]
       var records: [JSON] = []
       for (name, root, options) in fixtures {
           let renderer = JSONHTMLRenderer(options: options)
      @@ -41,6 +61,8 @@ for (name, root, options) in fixtures {
           records.append(try object([
               JSONMember("name", value: .string(name)),
               JSONMember("presentation", value: .string(options.presentation.rawValue)),
      +        JSONMember("layout", value: .string(options.layout.rawValue)),
      +        JSONMember("valueProfile", value: .string(options.valueProfile.rawValue)),
               JSONMember("annotations", value: .number(JSONNumber(integer: report.statistics.annotations))),
               JSONMember("nodes", value: .number(JSONNumber(integer: report.statistics.renderedJSONNodes))),
               JSONMember("domUpperBound", value: .number(JSONNumber(integer: report.statistics.domNodeUpperBound))),
      @@ -50,4 +72,4 @@ for (name, root, options) in fixtures {
               JSONMember("logicalPeakBytes", value: .number(JSONNumber(integer: report.statistics.operation.peakLogicalRetainedBytes))) ]))
       }
       try JSONWriter(options: .init(indentation: 2)).write(.array(records)).write(to: output.appendingPathComponent("fixtures.json"))
      -print("JSONHTML_BROWSER_FIXTURES passed count=10")
      +print("JSONHTML_BROWSER_FIXTURES passed count=15")
      diff --git a/Tools/JSONHTMLBrowser/browser.cjs b/Tools/JSONHTMLBrowser/browser.cjs
      index 598a8e3..341a3f3 100644
      --- a/Tools/JSONHTMLBrowser/browser.cjs
      +++ b/Tools/JSONHTMLBrowser/browser.cjs
      @@ -13,7 +13,7 @@ assert.equal(version, '1.62.1', 'Qualification requires the pinned Playwright ve
       const fixtures = path.resolve(process.argv[2]), out = path.resolve(process.argv[3]);
       fs.mkdirSync(out, {recursive: true});
       const records = JSON.parse(fs.readFileSync(path.join(fixtures, 'fixtures.json')));
      -assert.equal(records.length, 10);
      +assert.equal(records.length, 15);
       const sha = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
       const receipt = {status: 'running', playwright: version, fixtures: [], engines: [], cases: [], limitations: ['Screenshots are captured in Chromium; WebKit screenshots would inject a CSP-blocked synchronization stylesheet. WebKit DOM/layout/interaction checks still enforce CSP.', 'Playwright WebKit is not native Safari qualification.', 'Viewport tests do not qualify physical mobile browsers or VoiceOver.', 'Clipboard API acceptance is separate from platform clipboard transformations; exact downloads are independently compared.', 'Search chunking is not an instruction, CPU, heap or process RSS guarantee.']};
       for (const name of fs.readdirSync(fixtures).sort()) {
      @@ -43,7 +43,7 @@ async function main() {
           }
           try { browser = await playwright[engine].launch(launch); }
           catch (error) {
      -      receipt.engines.push({name: engine, status: 'blocked', error: error.message, executedCases: 0, unexecutedCases: engine === 'chromium' ? 16 : 15});
      +      receipt.engines.push({name: engine, status: 'blocked', error: error.message, executedCases: 0, unexecutedCases: engine === 'chromium' ? 26 : 25});
             save(); continue;
           }
           activeBrowsers.push(browser);
      @@ -62,7 +62,7 @@ async function main() {
               if (message.type() !== 'error') return;
               const text = message.text();
               if (engine === 'firefox' && text.includes('resource:///modules/FaviconLoader.sys.mjs') &&
      -            text.includes(origin + '/favicon.ico') && text.includes('(img-src)') && text.includes("default-src 'none'")) {
      +            text.includes(origin + '/favicon.ico') && text.includes('(img-src)') && (text.includes("default-src 'none'") || text.includes("img-src data:"))) {
                 browserPolicyEvents.push({kind: 'browser_automatic_favicon_denied', message: text});
               } else errors.push(text);
             });
      @@ -89,7 +89,7 @@ async function main() {
                 assert.equal(await page.locator('#jh-tree .node').count(), record.nodes);
                 assert.equal(await page.locator('#jh-toolbar').isVisible(), javaScriptEnabled);
                 assert.ok(await page.locator('.pointer-actions').count() > 0);
      -          assert.equal((await page.locator('.pointer-actions:visible').count()) > 0, javaScriptEnabled, 'Pointer actions require installed enhancement handlers');
      +          assert.equal((await page.locator('.pointer-actions:visible').count()) > 0, javaScriptEnabled && record.layout !== 'syntax', 'Pointer actions appear in the active structured view after enhancement');
               }
               await action(page, context, record);
               if (javaScriptEnabled) {
      @@ -133,17 +133,21 @@ async function main() {
           }
           await run('keyboard-outline', 'deep', 'http', true, async page => {
             await page.locator('.outline a').last().click();
      -      assert.equal(await page.locator('details[open]').count(), 4);
      +      assert.equal(await page.locator('#jh-tree details[open]').count(), 4);
             assert.equal(await page.evaluate(() => document.activeElement.id), 'jh-n-3');
             await page.getByRole('button', {name: 'Expand all', exact: true}).click(); await status(page, 'Expanded 40');
      -      assert.equal(await page.locator('details[open]').count(), 40);
      +      assert.equal(await page.locator('#jh-tree details[open]').count(), 40);
             await page.getByRole('button', {name: 'Collapse all', exact: true}).click(); await status(page, 'Collapsed 40');
      -      assert.equal(await page.locator('details[open]').count(), 0);
      +      assert.equal(await page.locator('#jh-tree details[open]').count(), 0);
           });
           await run('literal-search', 'wide', 'http', true, async page => {
             await page.locator('#jh-query').fill('[needle]'); await status(page, '150 matching nodes');
             assert.equal(await page.locator('#jh-results a').count(), 100);
      -      assert.equal(await page.locator('.search-hit').count(), 150);
      +      assert.equal(await page.locator('.search-hit').count(), 100);
      +      await page.getByRole('button', {name: 'Previous match', exact: true}).click();
      +      assert.equal(await page.evaluate(() => document.activeElement.id), 'jh-n-100');
      +      await page.getByRole('button', {name: 'Next match', exact: true}).click();
      +      assert.equal(await page.evaluate(() => document.activeElement.id), 'jh-n-1');
             const peakDOM = await page.evaluate(() => {let n = 0; const it = document.createTreeWalker(document, NodeFilter.SHOW_ALL); while (it.nextNode()) n++; return n;});
             assert.ok(peakDOM <= records.find(r => r.name === 'wide').domUpperBound);
             await page.locator('#jh-query').fill('Needle'); await status(page, '0 matching nodes');
      @@ -173,9 +177,12 @@ async function main() {
             await page.locator('#jh-n-1 > summary [data-action="copy-pointer"]').click(); await status(page, 'Clipboard unavailable or rejected');
             // Textareas normalize CRLF; fallback is visibly manual and does not promise byte fidelity.
             assert.equal(await page.locator('#jh-manual-text').inputValue(), raw.replace(/\r\n/g, '\n'));
      +      await page.keyboard.press('Escape');
      +      await page.waitForFunction(() => document.activeElement.dataset.action === 'copy-pointer');
             await page.locator('#jh-n-1 > summary [data-action="copy-pointer-escaped"]').click();
             await page.waitForFunction(() => document.getElementById('jh-manual-text').value.startsWith('"'));
             assert.equal(await page.locator('#jh-manual-text').inputValue(), '"/~01~1a\\u0000\\u000d\\u000a"');
      +      await page.getByRole('button', {name: 'Close', exact: true}).click();
             assert.equal(await page.locator('#jh-n-1').evaluate(n => n.open), true);
             await page.evaluate(() => Object.defineProperty(navigator, 'clipboard', {configurable: true, value: undefined}));
             await page.getByRole('button', {name: 'Copy JSON', exact: true}).click(); await status(page, 'Clipboard unavailable or rejected');
      @@ -187,7 +194,7 @@ async function main() {
             assert.equal(await page.evaluate(() => typeof window.pwned), 'undefined');
             const download = await downloaded(page, '[data-action="download-json"]');
             assert.deepEqual(download.bytes, fs.readFileSync(path.join(fixtures, 'hostile.json')));
      -      assert.equal(await page.locator('code[data-search]').textContent(), fs.readFileSync(path.join(fixtures, 'hostile.json'), 'utf8'));
      +      assert.ok((await page.locator('#jh-tree [data-search]').textContent()).includes('\\u2028\\u2029\\u0000'));
           });
           await run('mobile-layout', 'exact', 'http', true, async page => {
             assert.ok(await page.getByRole('button', {name: 'Download JSON (UTF-8)', exact: true}).isVisible());
      @@ -201,13 +208,20 @@ async function main() {
             if (engine === 'chromium') await page.screenshot({path: path.join(out, engine + '-desktop.png'), fullPage: true, caret: 'initial'});
           }, {viewport: {width: 390, height: 844}});
           await run('complete-print', 'deep', 'http', true, async page => {
      -      assert.equal(await page.locator('details[open]').count(), 0);
      +      assert.equal(await page.locator('#jh-tree details[open]').count(), 0);
      +      await page.setViewportSize({width: 320, height: 900});
      +      await page.locator('[data-action="expand"]').click();
      +      assert.equal(await page.locator('#jh-tree details:not([open])').count(), 0);
      +      assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, 'Expanded deep cards must stop accumulating indentation');
      +      await page.locator('[data-action="collapse"]').click();
             await page.emulateMedia({media: 'print'});
             assert.equal(await page.locator('#jh-tree').isVisible(), false);
             assert.equal(await page.locator('.print-json').isVisible(), true);
      -      assert.equal(await page.locator('.print-json code').textContent(), fs.readFileSync(path.join(fixtures, 'deep.json'), 'utf8'));
      +      assert.equal(await page.locator('.print-json .node').count(), records.find(r => r.name === 'deep').nodes);
      +      assert.ok((await page.locator('.print-json').textContent()).includes('last-sentinel'));
      +      assert.equal(await page.locator('.print-json details:not([open])').count(), 0);
             if (engine === 'chromium') await page.pdf({path: path.join(out, 'chromium-complete.pdf'), format: 'A4'});
      -      assert.equal(await page.locator('details[open]').count(), 0);
      +      assert.equal(await page.locator('#jh-tree details[open]').count(), 0);
           });
           await run('query-limit-no-outline', 'zero-query', 'http', true, async page => {
             await page.locator('#jh-query').fill('é'); await status(page, 'exceeds the UTF-8 byte limit');
      @@ -255,6 +269,73 @@ async function main() {
             await page.emulateMedia({media: 'print'});
             assert.ok(await page.locator('#jh-a-4').isVisible());
           });
      +    await run('tabs-keyboard', 'previews', 'http', true, async page => {
      +      assert.equal(await page.getByRole('tab', {name: 'Structured', exact: true}).getAttribute('aria-selected'), 'true');
      +      await page.getByRole('tab', {name: 'Structured', exact: true}).focus(); await page.keyboard.press('ArrowRight');
      +      assert.equal(await page.evaluate(() => document.activeElement.id), 'jh-tab-syntax');
      +      assert.equal(await page.locator('#jh-syntax').isVisible(), true);
      +      await page.keyboard.press('Home'); assert.equal(await page.locator('#jh-structured').isVisible(), true);
      +      await page.keyboard.press('End'); assert.equal(await page.locator('#jh-syntax').isVisible(), true);
      +    });
      +    await run('specialized-previews', 'previews', 'http', true, async page => {
      +      assert.equal(await page.locator('#jh-tree img').count(), 4);
      +      for (const image of await page.locator('#jh-tree img').all()) assert.equal(await image.evaluate(n => n.complete && n.naturalWidth === 4 && n.naturalHeight === 4), true);
      +      assert.equal(await page.locator('#jh-tree svg.color-swatch').count(), 2);
      +      assert.equal(await page.locator('#jh-tree math').count(), 1);
      +      assert.equal(await page.locator('#jh-tree math msubsup').count(), 1);
      +      assert.ok((await page.locator('#jh-tree').textContent()).includes('$x^12'));
      +      assert.equal(await page.locator('#jh-tree a[href^="https:"]').count(), 0);
      +      const ids = await page.locator('[id]').evaluateAll(nodes => nodes.map(n => n.id)); assert.equal(new Set(ids).size, ids.length);
      +      const download = await downloaded(page, '[data-action="download-json"]'); assert.deepEqual(download.bytes, fs.readFileSync(path.join(fixtures, 'previews.json')));
      +    });
      +    for (const protocol of ['file', 'http']) await run('static-previews-' + protocol, 'static-inspection', protocol, false, async page => {
      +      assert.equal(await page.locator('script').count(), 0); assert.equal(await page.locator('button,input').count(), 0);
      +      assert.equal(await page.locator('#jh-tree math').count(), 1);
      +      assert.equal(await page.locator('#jh-tree img').count(), 4);
      +      await page.locator('#jh-tree .original-value').last().locator('summary').click();
      +      assert.ok((await page.locator('#jh-tree').textContent()).includes('ORIGINAL-TAIL-ONLY'));
      +      assert.equal(await page.locator('#jh-tree details.node:not([open])').count(), 0);
      +      const d=await downloaded(page,'.source-download a');assert.deepEqual(d.bytes,fs.readFileSync(path.join(fixtures,'static-inspection.json')));
      +    });
      +    await run('no-script-both-views-math', 'previews', 'file', false, async page => {
      +      assert.equal(await page.locator('#jh-structured').isVisible(), true); assert.equal(await page.locator('#jh-syntax').isVisible(), true);
      +      assert.equal(await page.locator('#jh-tree math').isVisible(), true);
      +      assert.equal(await page.locator('#jh-syntax pre code').textContent(), fs.readFileSync(path.join(fixtures, 'previews.json'), 'utf8'));
      +    });
      +    await run('cutoff-native-original', 'inspection-long', 'file', false, async page => {
      +      assert.equal(await page.locator('#jh-tree .display-value').textContent(), '"' + '😀'.repeat(512) + '"');
      +      assert.equal(await page.locator('#jh-tree .original-value code').isVisible(), false);
      +      await page.locator('#jh-tree .original-value summary').click();
      +      assert.ok((await page.locator('#jh-tree .original-value code').textContent()).endsWith('ORIGINAL-TAIL-ONLY"'));
      +      assert.equal(await page.locator('#jh-tree .original-value code').isVisible(), true);
      +    });
      +    await run('search-displayed-preview-boundary', 'inspection-long', 'http', true, async page => {
      +      await page.locator('#jh-query').fill('ORIGINAL-TAIL-ONLY'); await status(page, '0 matching nodes');
      +      await page.getByRole('tab', {name: 'JSON source', exact: true}).click(); await status(page, '1 matching nodes');
      +      await page.locator('#jh-query').press('Enter'); assert.equal(await page.evaluate(() => document.activeElement.id), 'jh-syntax');
      +      await page.locator('#jh-query').focus(); await page.keyboard.press('Escape'); await status(page, '0 matching nodes');
      +    });
      +    await run('previews-320-css-200-percent', 'previews', 'http', true, async page => {
      +      assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
      +      await page.evaluate(() => {document.body.style.zoom = '2';});
      +      assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
      +      if (engine === 'chromium') await page.screenshot({path: path.join(out, 'chromium-previews-320-zoom.png'), fullPage: true, caret: 'initial'});
      +      await page.evaluate(() => {document.body.style.zoom = '';});
      +      if (engine === 'chromium') await page.screenshot({path: path.join(out, 'chromium-previews-320.png'), fullPage: true, caret: 'initial'});
      +    }, {viewport: {width: 320, height: 900}});
      +    await run('complete-structured-print-previews', 'complete', 'http', false, async page => {
      +      await page.emulateMedia({media: 'print'});
      +      assert.equal(await page.locator('#jh-tree').isVisible(), false); assert.equal(await page.locator('.print-json').isVisible(), true);
      +      assert.equal(await page.locator('.print-json details:not([open])').count(), 0);
      +      assert.equal(await page.locator('.print-json math').count(), 1); assert.equal(await page.locator('.print-json img').count(), 4);
      +      assert.ok((await page.locator('.print-json').textContent()).includes('😀'.repeat(513) + 'ORIGINAL-TAIL-ONLY'));
      +      if (engine === 'chromium') await page.pdf({path: path.join(out, 'chromium-previews-complete.pdf'), format: 'A4'});
      +    });
      +    await run('explicit-syntax-no-enrichment', 'syntax', 'file', true, async page => {
      +      assert.equal(await page.locator('#jh-syntax').isVisible(), true);
      +      assert.equal(await page.locator('math,img,svg.color-swatch').count(), 0);
      +      assert.equal(await page.locator('#jh-syntax pre code').textContent(), fs.readFileSync(path.join(fixtures, 'syntax.json'), 'utf8'));
      +    });
           if (engine === 'chromium') await run('native-browser-clipboard', 'exact', 'http', true, async (page, context) => {
             await context.grantPermissions(['clipboard-read', 'clipboard-write']);
             // Keep previous clipboard text in browser memory only; never put it in receipts.
      @@ -273,12 +354,12 @@ async function main() {
           });
           await browser.close();
         }
      -  receipt.expectedCases = 46; receipt.passedCases = receipt.cases.filter(c => c.status === 'passed').length;
      +  receipt.expectedCases = 76; receipt.passedCases = receipt.cases.filter(c => c.status === 'passed').length;
         receipt.unexecutedCases = receipt.engines.reduce((sum, e) => sum + (e.unexecutedCases || 0), 0);
         if (receipt.unexecutedCases) {receipt.status = 'blocked'; save(); console.error('Browser startup blocked; unexecuted cases=' + receipt.unexecutedCases); process.exitCode = 2; return;}
      -  assert.equal(receipt.cases.length, 46);
      -  assert.equal(receipt.cases.filter(c => c.status === 'passed').length, 46);
      -  receipt.status = 'passed'; receipt.expectedCases = 46; receipt.passedCases = 46; receipt.unexpectedSkips = 0;
      -  save(); console.log('JSONHTML_BROWSER passed engines=3 cases=46 skips=0');
      +  assert.equal(receipt.cases.length, 76);
      +  assert.equal(receipt.cases.filter(c => c.status === 'passed').length, 76);
      +  receipt.status = 'passed'; receipt.expectedCases = 76; receipt.passedCases = 76; receipt.unexpectedSkips = 0;
      +  save(); console.log('JSONHTML_BROWSER passed engines=3 cases=76 skips=0');
       }
       main().catch(error => {receipt.status = 'failed'; receipt.error = error.stack; save(); console.error(error); process.exitCode = 1;}).finally(async () => {await Promise.all(activeBrowsers.map(b => b.close().catch(() => {}))); for (const data of isolatedDataDirectories) fs.rmSync(data, {recursive: true, force: true}); server.close();});
      diff --git a/Tools/JSONHTMLExport/Sources/ExportConsumer/main.swift b/Tools/JSONHTMLExport/Sources/ExportConsumer/main.swift
      index 1dacbc1..ebb71f6 100644
      --- a/Tools/JSONHTMLExport/Sources/ExportConsumer/main.swift
      +++ b/Tools/JSONHTMLExport/Sources/ExportConsumer/main.swift
      @@ -2,26 +2,34 @@
       import Foundation
       import JSON
       import JSONHTML
      +import JSONPatch
       import JSONSchema
       import JSONStreaming
      -import JSONPatch
      +
       #if canImport(AppKit)
      -import AppKit
      -import PDFKit
      +    import AppKit
      +    import PDFKit
       #elseif canImport(UIKit)
      -import UIKit
      -import PDFKit
      +    import UIKit
      +    import PDFKit
       #endif
       #if canImport(CoreTransferable) && canImport(UniformTypeIdentifiers)
      -import CoreTransferable
      -import UniformTypeIdentifiers
      +    import CoreTransferable
      +    import UniformTypeIdentifiers
       #endif
       
       struct FixtureRegistry: Decodable {
      -    struct Fixture: Decodable { let path: String; let sha256: String; let expected_native_parse: String }
      +    struct Fixture: Decodable {
      +        let path: String
      +        let sha256: String
      +        let expected_native_parse: String
      +    }
           let fixtures: [Fixture]
       }
      -struct Observation: Codable { let name: String; let passed: Bool }
      +struct Observation: Codable {
      +    let name: String
      +    let passed: Bool
      +}
       struct Receipt: Codable {
           let status: String
           let cases: [Observation]
      @@ -56,173 +64,299 @@ struct PDFObservation: Codable {
               var cases: [Observation] = []
               func check(_ name: String, _ passed: Bool) throws {
                   cases.append(.init(name: name, passed: passed))
      -            if !passed { throw CocoaError(.fileReadCorruptFile) }
      +            if !passed {
      +                FileHandle.standardError.write(Data(("EXPORT_CONSUMER_FAILED " + name + "\n").utf8))
      +                throw CocoaError(.fileReadCorruptFile)
      +            }
               }
               let fixtureRoot = Bundle.module.resourceURL!.appendingPathComponent("Fixtures")
      -        let registry = try JSONDecoder().decode(FixtureRegistry.self, from: Data(contentsOf: fixtureRoot.appendingPathComponent("registry.json")))
      +        let registry = try JSONDecoder().decode(
      +            FixtureRegistry.self,
      +            from: Data(contentsOf: fixtureRoot.appendingPathComponent("registry.json")))
               for fixture in registry.fixtures {
      -            let bytes = try Data(contentsOf: fixtureRoot.appendingPathComponent(URL(fileURLWithPath: fixture.path).lastPathComponent))
      +            let bytes = try Data(
      +                contentsOf: fixtureRoot.appendingPathComponent(
      +                    URL(fileURLWithPath: fixture.path).lastPathComponent))
                   if fixture.expected_native_parse == "accepted" {
                       let parsed = try JSONParser().parse(bytes)
                       let prepared = try JSONExportDocument.prepare(parsed)
      -                try check("fixture-" + fixture.path, try JSONParser().parse(prepared.data(for: .json)) == parsed)
      +                try check(
      +                    "fixture-" + fixture.path,
      +                    try JSONParser().parse(prepared.data(for: .json)) == parsed)
                   } else {
                       var rejected = false
                       do { _ = try JSONParser().parse(bytes) } catch is JSONFailure { rejected = true }
                       try check("fixture-" + fixture.path, rejected)
                   }
               }
      -        let source = #"{"é":9007199254740993,"é":-0.00e+2,"empty":[],"end":"LAST-CONTENT-SENTINEL😀"}"#
      +        let source =
      +            #"{"é":9007199254740993,"é":-0.00e+2,"empty":[],"end":"LAST-CONTENT-SENTINEL😀"}"#
               let value = try JSONParser().parse(source)
      -        let report = try JSONExportDocument.prepareWithReport(value, options: .init(filenameStem: "Exact-JSON"))
      +        let report = try JSONExportDocument.prepareWithReport(
      +            value, options: .init(filenameStem: "Exact-JSON"))
               let item = report.result
               try check("available-four", item.availableFormats == [.json, .plainText, .markdown, .html])
               try check("advertised-four", item.advertisedFormats == item.availableFormats)
               try check("one-root-admission", report.operation[.nodes] == 5)
      -        try check("aggregate-artifact-sum", report.preparedBytes == report.representationBytes.values.reduce(0, +))
      -        try check("exact-native-roundtrip", try JSONWriter(options: .init(numberStyle: .preserved)).write(JSONParser().parse(item.data(for: .json))) == Data(source.utf8))
      +        try check(
      +            "aggregate-artifact-sum",
      +            report.preparedBytes == report.representationBytes.values.reduce(0, +))
      +        try check(
      +            "exact-native-roundtrip",
      +            try JSONWriter(options: .init(numberStyle: .preserved)).write(
      +                JSONParser().parse(item.data(for: .json))) == Data(source.utf8))
               try check("pretty-plain-body", try item.data(for: .json) == item.data(for: .plainText))
               var samples: [String: Int] = [:]
               for format in [JSONExportFormat.json, .plainText, .markdown, .html] {
                   let bytes = try item.data(for: format)
                   let name = "Exact-JSON." + format.filenameExtension
      -            try bytes.write(to: output.appendingPathComponent(name)); samples[name] = bytes.count
      +            try bytes.write(to: output.appendingPathComponent(name))
      +            samples[name] = bytes.count
                   let selected = try item.selecting([format])
      -            try check("selection-" + format.rawValue, selected.advertisedFormats == [format] && selected.availableFormats == item.availableFormats)
      +            try check(
      +                "selection-" + format.rawValue,
      +                selected.advertisedFormats == [format]
      +                    && selected.availableFormats == item.availableFormats)
               }
               #if canImport(CoreTransferable) && canImport(UniformTypeIdentifiers)
      -        try check("direct-native-import-only", JSON.importedContentTypes() == [.json])
      -        try check("prepared-export-only", JSONExportDocument.importedContentTypes().isEmpty)
      -        for (format, type) in [(JSONExportFormat.json, UTType.json), (.html, .html), (.plainText, .utf8PlainText), (.markdown, .markdown)] {
      -            let selected = try item.selecting([format])
      -            try check("instance-discovery-" + format.rawValue, selected.exportedContentTypes() == [type])
      -            try check("transfer-bytes-" + format.rawValue, try await selected.exported(as: type) == item.data(for: format))
      -        }
      -        let fileDirectory = output.appendingPathComponent("SystemExport", isDirectory: true)
      -        try FileManager.default.createDirectory(at: fileDirectory, withIntermediateDirectories: true)
      -        let path = try await item.selecting([.json]).export(to: fileDirectory, contentType: .json)
      -        try check("system-filename-and-file", path.lastPathComponent == "Exact-JSON.json" && Data(contentsOf: path) == item.data(for: .json))
      -        let imported = try await JSON(importing: path, contentType: .json)
      -        try check("system-file-strict-import", try JSONWriter(options: .init(numberStyle: .preserved)).write(imported) == Data(source.utf8))
      +            try check("direct-native-import-only", JSON.importedContentTypes() == [.json])
      +            try check("prepared-export-only", JSONExportDocument.importedContentTypes().isEmpty)
      +            for (format, type) in [
      +                (JSONExportFormat.json, UTType.json), (.html, .html), (.plainText, .utf8PlainText),
      +                (.markdown, .markdown),
      +            ] {
      +                let selected = try item.selecting([format])
      +                try check(
      +                    "instance-discovery-" + format.rawValue,
      +                    selected.exportedContentTypes() == [type])
      +                try check(
      +                    "transfer-bytes-" + format.rawValue,
      +                    try await selected.exported(as: type) == item.data(for: format))
      +            }
      +            let fileDirectory = output.appendingPathComponent("SystemExport", isDirectory: true)
      +            try FileManager.default.createDirectory(
      +                at: fileDirectory, withIntermediateDirectories: true)
      +            let path = try await item.selecting([.json]).export(
      +                to: fileDirectory, contentType: .json)
      +            try check(
      +                "system-filename-and-file",
      +                path.lastPathComponent == "Exact-JSON.json"
      +                    && Data(contentsOf: path) == item.data(for: .json))
      +            let imported = try await JSON(importing: path, contentType: .json)
      +            try check(
      +                "system-file-strict-import",
      +                try JSONWriter(options: .init(numberStyle: .preserved)).write(imported)
      +                    == Data(source.utf8))
               #endif
      -        let rich = try JSONExportDocument.prepareWithReport(value, options: .init(formats: [.json, .html, .rtf, .plainText, .markdown], filenameStem: "Exact-JSON"))
      -        try check("rtf-explicit-five", rich.result.availableFormats == [.json, .html, .rtf, .plainText, .markdown])
      +        let rich = try JSONExportDocument.prepareWithReport(
      +            value,
      +            options: .init(
      +                formats: [.json, .html, .rtf, .plainText, .markdown], filenameStem: "Exact-JSON"))
      +        try check(
      +            "rtf-explicit-five",
      +            rich.result.availableFormats == [.json, .html, .rtf, .plainText, .markdown])
               try check("rtf-one-admission", rich.operation[.nodes] == 5)
               try check("rtf-cumulative-work", rich.operation[.workUnits] > report.operation[.workUnits])
      -        try check("rtf-aggregate", rich.preparedBytes == rich.representationBytes.values.reduce(0, +))
      +        try check(
      +            "rtf-aggregate", rich.preparedBytes == rich.representationBytes.values.reduce(0, +))
               let rtfBytes = try rich.result.data(for: .rtf)
      -        try rtfBytes.write(to: output.appendingPathComponent("Exact-JSON.rtf")); samples["Exact-JSON.rtf"] = rtfBytes.count
      +        try rtfBytes.write(to: output.appendingPathComponent("Exact-JSON.rtf"))
      +        samples["Exact-JSON.rtf"] = rtfBytes.count
               #if canImport(AppKit) || canImport(UIKit)
      -        let decoded = try NSAttributedString(data: rtfBytes, options: [.documentType: NSAttributedString.DocumentType.rtf], documentAttributes: nil)
      -        try check("rtf-native-text", try Data(decoded.string.utf8) == rich.result.data(for: .plainText) && decoded.length == decoded.string.utf16.count)
      -        let key = (decoded.string as NSString).range(of: "\"é\"")
      -        let number = (decoded.string as NSString).range(of: "9007199254740993")
      -        #if canImport(AppKit)
      -        let font = decoded.attribute(.font, at: key.location, effectiveRange: nil) as? NSFont
      -        #else
      -        let font = decoded.attribute(.font, at: key.location, effectiveRange: nil) as? UIFont
      -        #endif
      -        let keyColor = decoded.attribute(.foregroundColor, at: key.location, effectiveRange: nil) as? NSObject
      -        let numberColor = decoded.attribute(.foregroundColor, at: number.location, effectiveRange: nil) as? NSObject
      -        try check("rtf-native-styles", font?.fontName.contains("Menlo") == true && font?.pointSize == 11 && keyColor != nil && numberColor != nil && keyColor != numberColor)
      +            let decoded = try NSAttributedString(
      +                data: rtfBytes, options: [.documentType: NSAttributedString.DocumentType.rtf],
      +                documentAttributes: nil)
      +            try check(
      +                "rtf-native-text",
      +                try Data(decoded.string.utf8) == rich.result.data(for: .plainText)
      +                    && decoded.length == decoded.string.utf16.count)
      +            let key = (decoded.string as NSString).range(of: "\"é\"")
      +            let number = (decoded.string as NSString).range(of: "9007199254740993")
      +            #if canImport(AppKit)
      +                let font =
      +                    decoded.attribute(.font, at: key.location, effectiveRange: nil) as? NSFont
      +            #else
      +                let font =
      +                    decoded.attribute(.font, at: key.location, effectiveRange: nil) as? UIFont
      +            #endif
      +            let keyColor =
      +                decoded.attribute(.foregroundColor, at: key.location, effectiveRange: nil)
      +                as? NSObject
      +            let numberColor =
      +                decoded.attribute(.foregroundColor, at: number.location, effectiveRange: nil)
      +                as? NSObject
      +            try check(
      +                "rtf-native-styles",
      +                font?.fontName.contains("Menlo") == true && font?.pointSize == 11 && keyColor != nil
      +                    && numberColor != nil && keyColor != numberColor)
               #endif
               let richOnly = try rich.result.selecting([.rtf])
      -        try check("rtf-selection", richOnly.advertisedFormats == [.rtf] && richOnly.availableFormats.count == 5)
      +        try check(
      +            "rtf-selection",
      +            richOnly.advertisedFormats == [.rtf] && richOnly.availableFormats.count == 5)
               #if canImport(CoreTransferable) && canImport(UniformTypeIdentifiers)
      -        var providerNamesMatch = true
      -        for (format, type) in [(JSONExportFormat.json, UTType.json), (.html, .html),
      -                              (.rtf, .rtf), (.plainText, .utf8PlainText), (.markdown, .markdown)] {
      -            let selected = try rich.result.selecting([format])
      -            let provider = NSItemProvider()
      -            provider.register(selected)
      -            providerNamesMatch = providerNamesMatch && provider.registeredTypeIdentifiers == [type.identifier]
      -                && provider.suggestedName == "Exact-JSON." + format.filenameExtension
      -        }
      -        try check("provider-selected-types-and-filenames", providerNamesMatch)
      +            var providerNamesMatch = true
      +            for (format, type) in [
      +                (JSONExportFormat.json, UTType.json), (.html, .html),
      +                (.rtf, .rtf), (.plainText, .utf8PlainText), (.markdown, .markdown),
      +            ] {
      +                let selected = try rich.result.selecting([format])
      +                let provider = NSItemProvider()
      +                provider.register(selected)
      +                providerNamesMatch =
      +                    providerNamesMatch && provider.registeredTypeIdentifiers == [type.identifier]
      +                    && provider.suggestedName == "Exact-JSON." + format.filenameExtension
      +            }
      +            try check("provider-selected-types-and-filenames", providerNamesMatch)
               #endif
       
               #if canImport(CoreTransferable) && canImport(UniformTypeIdentifiers)
      -        try check("rtf-content-type", richOnly.exportedContentTypes() == [.rtf])
      -        try check("rtf-transfer-bytes", try await richOnly.exported(as: .rtf) == rtfBytes)
      -        let richPath = try await richOnly.export(to: fileDirectory, contentType: .rtf)
      -        try check("rtf-system-filename", richPath.lastPathComponent == "Exact-JSON.rtf" && Data(contentsOf: richPath) == rtfBytes)
      +            try check("rtf-content-type", richOnly.exportedContentTypes() == [.rtf])
      +            try check("rtf-transfer-bytes", try await richOnly.exported(as: .rtf) == rtfBytes)
      +            let richPath = try await richOnly.export(to: fileDirectory, contentType: .rtf)
      +            try check(
      +                "rtf-system-filename",
      +                richPath.lastPathComponent == "Exact-JSON.rtf"
      +                    && Data(contentsOf: richPath) == rtfBytes)
               #endif
               var pdfReports: [PDFObservation] = []
               #if canImport(WebKit) && canImport(PDFKit)
      -        #if canImport(AppKit)
      -        _ = NSApplication.shared
      -        #endif
      -        let pdfReport = try await rich.result.preparingPDFWithReport()
      -        try check("pdf-explicit-six", pdfReport.result.availableFormats.count == 6 && !rich.result.availableFormats.contains(.pdf))
      -        try check("pdf-native-unchanged", try pdfReport.result.data(for: .json) == rich.result.data(for: .json))
      -        try check("pdf-no-root-readmission", pdfReport.operation[.nodes] == 0)
      -        try check("pdf-input-html", try pdfReport.inputHTMLBytes == UInt64(rich.result.data(for: .html).count))
      -        try check("pdf-cumulative-output", pdfReport.operation[.outputBytes] == pdfReport.capturedPDFBytes + pdfReport.outputPDFBytes)
      -        let pdfOnly = try pdfReport.result.selecting([.pdf])
      -        #if canImport(CoreTransferable) && canImport(UniformTypeIdentifiers)
      -        try check("pdf-content-type", pdfOnly.exportedContentTypes() == [.pdf])
      -        let pdfProvider = NSItemProvider()
      -        pdfProvider.register(pdfOnly)
      -        try check("pdf-provider-type-and-filename", pdfProvider.registeredTypeIdentifiers == [UTType.pdf.identifier] && pdfProvider.suggestedName == "Exact-JSON.pdf")
      -        try check("pdf-transfer-bytes", try await pdfOnly.exported(as: .pdf) == pdfReport.result.data(for: .pdf))
      -        let pdfPath = try await pdfOnly.export(to: fileDirectory, contentType: .pdf)
      -        try check("pdf-system-filename", try pdfPath.lastPathComponent == "Exact-JSON.pdf" && Data(contentsOf: pdfPath) == pdfReport.result.data(for: .pdf))
      -        #endif
      -        try check("pdf-original-unavailable", !rich.result.advertisedFormats.contains(.pdf))
      -        let rows = (0..<150).map { JSON.string("ROW-\($0)-END 9007199254740993 é e\u{301} 😀 العربية https://example.test/wide/long/value") }
      -        let pageItem = try JSONExportDocument.prepare(.array(rows + [.string("LAST-CONTENT-SENTINEL")]), options: .init(formats: [.html]))
      -        for options in [JSONPDFOptions(), .init(paper: .usLetter), .init(layout: .continuous)] {
      -            let rendered = try await pageItem.preparingPDFWithReport(options: options)
      -            let bytes = try rendered.result.data(for: .pdf)
      -            let pdf = try PDFDocument(data: bytes).unwrap()
      -            let name = options.layout == .continuous ? "Continuous.pdf" : options.paper == .a4 ? "A4.pdf" : "Letter.pdf"
      -            try bytes.write(to: output.appendingPathComponent(name)); samples[name] = bytes.count
      -            if options.layout == .paginated {
      -                let width = options.paper == .a4 ? 595.275590551 : 612.0
      -                let height = options.paper == .a4 ? 841.889763780 : 792.0
      -                try check("pdf-geometry-" + name, pdf.pageCount >= 3 && (0.. 2_000)
      +            #if canImport(AppKit)
      +                _ = NSApplication.shared
      +            #endif
      +            let pdfReport = try await rich.result.preparingPDFWithReport()
      +            try check(
      +                "pdf-explicit-six",
      +                pdfReport.result.availableFormats.count == 6
      +                    && !rich.result.availableFormats.contains(.pdf))
      +            try check(
      +                "pdf-native-unchanged",
      +                try pdfReport.result.data(for: .json) == rich.result.data(for: .json))
      +            try check("pdf-no-root-readmission", pdfReport.operation[.nodes] == 0)
      +            try check(
      +                "pdf-input-html",
      +                try pdfReport.inputHTMLBytes == UInt64(rich.result.data(for: .html).count))
      +            try check(
      +                "pdf-cumulative-output",
      +                pdfReport.operation[.outputBytes] == pdfReport.capturedPDFBytes
      +                    + pdfReport.outputPDFBytes)
      +            let pdfOnly = try pdfReport.result.selecting([.pdf])
      +            #if canImport(CoreTransferable) && canImport(UniformTypeIdentifiers)
      +                try check("pdf-content-type", pdfOnly.exportedContentTypes() == [.pdf])
      +                let pdfProvider = NSItemProvider()
      +                pdfProvider.register(pdfOnly)
      +                try check(
      +                    "pdf-provider-type-and-filename",
      +                    pdfProvider.registeredTypeIdentifiers == [UTType.pdf.identifier]
      +                        && pdfProvider.suggestedName == "Exact-JSON.pdf")
      +                try check(
      +                    "pdf-transfer-bytes",
      +                    try await pdfOnly.exported(as: .pdf) == pdfReport.result.data(for: .pdf))
      +                let pdfPath = try await pdfOnly.export(to: fileDirectory, contentType: .pdf)
      +                try check(
      +                    "pdf-system-filename",
      +                    try pdfPath.lastPathComponent == "Exact-JSON.pdf"
      +                        && Data(contentsOf: pdfPath) == pdfReport.result.data(for: .pdf))
      +            #endif
      +            try check("pdf-original-unavailable", !rich.result.advertisedFormats.contains(.pdf))
      +            let rows = (0..<150).map {
      +                JSON.string(
      +                    "ROW-\($0)-END 9007199254740993 é e\u{301} 😀 العربية https://example.test/wide/long/value"
      +                )
                   }
      -            try check("pdf-last-page-" + name, pdf.page(at: pdf.pageCount - 1)?.string?.contains("LAST-CONTENT-SENTINEL") == true)
      -            let elapsed = rendered.elapsed.components
      -            pdfReports.append(.init(layout: options.layout.rawValue, paper: options.paper.rawValue,
      -                pages: rendered.pageCount, bytes: rendered.outputPDFBytes, capturedBytes: rendered.capturedPDFBytes,
      -                work: rendered.operation[.workUnits], peakLogicalBytes: rendered.operation.peakLogicalRetainedBytes,
      -                elapsedMilliseconds: Double(elapsed.seconds) * 1_000 + Double(elapsed.attoseconds) / 1e15, backend: rendered.backend))
      -        }
      -        let cancelled = Task { @MainActor in try await rich.result.preparingPDF() }
      -        cancelled.cancel()
      -        var cancellationRejected = false
      -        do { _ = try await cancelled.value } catch is CancellationError { cancellationRejected = true }
      -        try check("pdf-pre-cancellation", cancellationRejected && !rich.result.availableFormats.contains(.pdf))
      +            let pageItem = try JSONExportDocument.prepare(
      +                .array(rows + [.string("LAST-CONTENT-SENTINEL")]), options: .init(formats: [.html]))
      +            for options in [JSONPDFOptions(), .init(paper: .usLetter), .init(layout: .continuous)] {
      +                let rendered = try await pageItem.preparingPDFWithReport(options: options)
      +                let bytes = try rendered.result.data(for: .pdf)
      +                let pdf = try PDFDocument(data: bytes).unwrap()
      +                let name =
      +                    options.layout == .continuous
      +                    ? "Continuous.pdf" : options.paper == .a4 ? "A4.pdf" : "Letter.pdf"
      +                try bytes.write(to: output.appendingPathComponent(name))
      +                samples[name] = bytes.count
      +                if options.layout == .paginated {
      +                    let width = options.paper == .a4 ? 595.275590551 : 612.0
      +                    let height = options.paper == .a4 ? 841.889763780 : 792.0
      +                    try check(
      +                        "pdf-geometry-" + name,
      +                        pdf.pageCount >= 3
      +                            && (0.. 2_000)
      +                }
      +                try check(
      +                    "pdf-last-page-" + name,
      +                    pdf.page(at: pdf.pageCount - 1)?.string?.filter({ !$0.isWhitespace }).contains(
      +                        "LAST-CONTENT-SENTINEL") == true)
      +                let elapsed = rendered.elapsed.components
      +                pdfReports.append(
      +                    .init(
      +                        layout: options.layout.rawValue, paper: options.paper.rawValue,
      +                        pages: rendered.pageCount, bytes: rendered.outputPDFBytes,
      +                        capturedBytes: rendered.capturedPDFBytes,
      +                        work: rendered.operation[.workUnits],
      +                        peakLogicalBytes: rendered.operation.peakLogicalRetainedBytes,
      +                        elapsedMilliseconds: Double(elapsed.seconds) * 1_000 + Double(
      +                            elapsed.attoseconds) / 1e15, backend: rendered.backend))
      +            }
      +            let cancelled = Task { @MainActor in try await rich.result.preparingPDF() }
      +            cancelled.cancel()
      +            var cancellationRejected = false
      +            do { _ = try await cancelled.value } catch is CancellationError {
      +                cancellationRejected = true
      +            }
      +            try check(
      +                "pdf-pre-cancellation",
      +                cancellationRejected && !rich.result.availableFormats.contains(.pdf))
               #endif
               var times: [Double] = []
               let clock = ContinuousClock()
               for _ in 0..<7 {
                   let start = clock.now
                   let measured = try JSONExportDocument.prepareWithReport(value)
      -            try check("measurement-complete-\(times.count)", measured.result.availableFormats.count == 4)
      +            try check(
      +                "measurement-complete-\(times.count)", measured.result.availableFormats.count == 4)
                   let elapsed = start.duration(to: clock.now).components
                   times.append(Double(elapsed.seconds) * 1_000 + Double(elapsed.attoseconds) / 1e15)
               }
      -        let receipt = Receipt(status: "passed", cases: cases, samples: samples, timingsMilliseconds: times,
      +        let receipt = Receipt(
      +            status: "passed", cases: cases, samples: samples, timingsMilliseconds: times,
                   preparedBytes: report.preparedBytes, workUnits: report.operation[.workUnits],
                   peakLogicalRetainedBytes: report.operation.peakLogicalRetainedBytes,
                   rtfPreparedBytes: rich.preparedBytes, rtfWorkUnits: rich.operation[.workUnits],
                   rtfPeakLogicalRetainedBytes: rich.operation.peakLogicalRetainedBytes,
      -            pdfReports: pdfReports, limitations: ["API/file transport consumer, not share-sheet, drag/drop or recipient UI qualification.",
      -                "PDF native generation/text/geometry is independent of receiving-app UI and physical-device integration.", "Logical retention is not RSS; tiny sample timing does not prove throughput ceilings."])
      -        let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
      +            pdfReports: pdfReports,
      +            limitations: [
      +                "API/file transport consumer, not share-sheet, drag/drop or recipient UI qualification.",
      +                "PDF native generation/text/geometry is independent of receiving-app UI and physical-device integration.",
      +                "Logical retention is not RSS; tiny sample timing does not prove throughput ceilings.",
      +            ])
      +        let encoder = JSONEncoder()
      +        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
               try encoder.encode(receipt).write(to: output.appendingPathComponent("consumer.json"))
      -        print("JSONHTML_EXPORT_CONSUMER passed cases=\(cases.count) artifacts=\(samples.count) measurements=\(times.count)")
      +        print(
      +            "JSONHTML_EXPORT_CONSUMER passed cases=\(cases.count) artifacts=\(samples.count) measurements=\(times.count)"
      +        )
           }
       }
       
      -private extension Optional {
      -    func unwrap() throws -> Wrapped { guard let value = self else { throw CocoaError(.fileReadCorruptFile) }; return value }
      +extension Optional {
      +    fileprivate func unwrap() throws -> Wrapped {
      +        guard let value = self else { throw CocoaError(.fileReadCorruptFile) }
      +        return value
      +    }
       }
      diff --git a/Tools/WasmAudit/README.md b/Tools/WasmAudit/README.md
      index 2bf46f0..0709c10 100644
      --- a/Tools/WasmAudit/README.md
      +++ b/Tools/WasmAudit/README.md
      @@ -27,7 +27,7 @@ wasmkit run --dir /absolute/path /absolute/path/WasmAudit.wasm schema
       ```
       
       `missing-resources` intentionally runs without a preopen and requires a structured
      -meta-schema failure, with no trap. The 13 Swift Testing functions have the same
      +meta-schema failure, with no trap. The original 13 Swift Testing functions have the same
       public semantics on native and WASI. The broader root/corpus/49-cap/frozen consumer
       qualification runs through `python3 Scripts/ci.py --lane wasm`; source paths and
       private build outputs stay outside archives. `--repo` grants fixture access only
      @@ -38,6 +38,6 @@ cooperative cancellation, safety/measurement policy and unqualified host modes.
       
       The integrated JSONHTML consumer adds the fifth public product and a fourteenth
       Swift Testing function covering six exact HTML/export/budget/cancellation/backend
      -checks. Node runs all six root test bundles (including 49 portable HTML functions);
      +checks. Node runs all six root test bundles (including 81 portable HTML functions);
       Node and WasmKit run the 14-function public consumer. Apple transfer and PDF are
       not portable backend claims. See [receiving qualification](../../Documentation/Architecture/Release1.3/ReceivingQualification.md).