feat(preview): layout preview inside the QBit: service, action and record widget (P6b); bound preview painting and pin the version 1 job frame #17

Merged
james.maes merged 7 commits from feat/preview-ui into develop 2026-11-12 20:50:56 +00:00
Owner

Package P6b of docs/design/LABEL-LAYOUT-FONTS-PREVIEW.md (sections 6.2 to 6.4, 9 and 10), plus two findings from the independent review of the bridge package.

Why

A person editing a label template must see the exact picture that would print, with its warnings beside it, before anything is printed, from inside the document module's own screens and with no code in the host application.

What a person sees

  1. On a template's record screen there is a new section, "Label preview", directly below the overview. It shows the current draft drawn with the first sample the scene declares, at the stock's own resolution: a line saying whether the picture is exact and whether the label can print, the picture, its size in dots and millimetres, the output size and hash, and one line per refusal, warning and note. A person who may only view sees the published revision instead. No record is ever read for this section.
  2. "Preview layout" is a new action on a template (its current draft) and on a published revision. The form asks for the data (a sample written in the scene, "longest values", or a record), the sample name, the record when one is wanted, optionally another stock, and a resolution (203, 300 or 600 dpi; blank is the stock's own). Submitting shows the same widget drawn from those choices.
  3. The existing action is relabelled "Generate retained preview file" and is otherwise unchanged (same process name, same behaviour): it stays the way to obtain an audited file, and the only preview of a PDF.

How

  • DocumentPreviewService (new, public): authorises, loads the draft or the published snapshot without writing anything, builds the context and calls IsolatedRenderer.preview. A real record is read as the person (GetAction with permissions and record locks enforced) before any context is built; the context is then built exactly as generation builds it, including the pinned context script, and the record is read again after the script as generation does. It logs one line (actor, template or revision id, data kind, dpi, outcome, diagnostic codes) and no content.
  • GenerationPreviewWidget (filled): one composite widget (IMAGE block with the page's PNG as a data URL, shown at the label's physical width, between TEXT blocks), used by the record screen section and by the action's result step.
  • GenerationPreviewProcesses (filled): the action on the template and revision tables, its two choice lists, and the widget's registration.
  • Help text for the process, both steps, every field, the section, the widget and the two choice lists is carried by the metadata, as the rest of this repository does. A test walks HelpTargetCatalog and requires help on every new target.

Permissions (no new permission)

Preview of Data Needs
A draft Sample or longest values document-generation.configure
A draft A record configure and generate, and read access to the record
A published revision Sample or longest values document-generation.view
A published revision A record generate, and read access to the record

A revision that is named must be a publication: the revision table also holds the frozen drafts that the retained preview writes, and such a row (any row whose purpose is not publication) is answered "Document definition not found.", exactly as an identity that does not exist. A draft is previewed through its template alone, which needs configure.

A record the person cannot read (no table permission, a record lock, or no such record) is refused as "Source record not found." before the script or the renderer runs, and nothing about it is returned. The record selector now asks for generate alone (it also asked for configure), so a person who may preview a published revision with a record can choose one; every record offered is still read as that person.

Nothing is stored, including in process state

QQQ stores the state of a process after every backend step and never removes it when the process ends: the in-memory provider drops it five hours after the process started, and the shared table provider keeps a row for twelve hours by default. So the picture is never a process value. The result step names the widget; the client requests it by the identifier of the run; the widget reads the person's choices from the state of that run; the request is authorised and drawn like any other; the picture, the values and the diagnostics exist only in that answer. The process state holds the choices only (template or revision id, data source, sample name, source table, record id, stock id, resolution), the form's choices as one structured value that a client does not send as a request parameter. A test runs the whole action with a real record through RunProcessAction and asserts the stored state holds no picture, no hash, no context and no value of the record, and that no table gained a row.

What reaches the browser is bounded: the first page, a picture of at most 2 MiB (above that, the facts and diagnostics without the picture), and fifty diagnostics with a count of the rest.

The frontend spike

A composite widget with an IMAGE block fed by a data URL renders as an <img> in the frontend a host serves (checked against release v1.0.0-RC.39 of qqq-frontend-next): seeded in a process step, fetched in a process step, and in a record screen section naming the widget. This was run as throwaway component tests in a simulated browser document (jsdom) with the payload shape the backend block classes serialise to, which a test in this repository asserts. It was not run in a real browser against a running host, and the design's "one browser check in the host's local stack" is not done. The FILE_PREVIEW fallback was not needed. No frontend change.

Where the code differs from the design (also recorded in the design, section 6.4)

  • The result step fetches the widget instead of being seeded from a process value, for the retention reason above.
  • No draft fingerprint on the service: nothing is saved against a preview.
  • The scene schema allows sixteen samples where the design says eight; the schema is followed.
  • "Longest values" on the host uses a capital W repeated to each declared maxLength: the host has no fonts and cannot know a face's widest glyph. W is not the widest glyph of every face, so the picture is indicative and the form's help says so. The renderer's validation at publication remains the proof of fit.
  • A version 1 scene has no samples, so its sample preview has an empty context.
  • This repository has no help bundle file; help is on the metadata.

Review findings on the bridge

A. Preview painting could burn host CPU on a hostile or broken worker's output. ZplRaster painted a box and a placeholder by testing every dot of the field's area, and the output policy allows 6000 fields. Now every painter counts the dots it is about to paint, an overlap each time, and the field that would take the page past sixteen times its dots (the renderer's own limit for a scene) is refused as PREVIEW_TOO_LARGE, which preview() returns as a result (printable=false, no page, limit.paintedPercent 1600). A box is one fill or four non-overlapping bars written whole bytes at a time; a placeholder is a one-dot outline with its two diagonals (its look changes: the dense diagonal hatch is gone, and three existing expectations were updated for that). A refused scene whose boxes would pass the budget has no page and gains the diagnostic. Tests: ZplRasterTest.thousandsOfPageSizedBoxesAreRefusedOrCostOnlyTheirInk, thePaintedBudgetIsExactForEveryPainter, theRefusedPageIsBoundedHoweverManyBoxesItHas, aBoxIsTheSameDotsAsPaintingItDotByDot (11,336 boxes against a per-dot reference; it passes before and after by design), PreviewTest.aLabelThatWouldPaintTooMuchIsRefusedAsAResult, aRefusedSceneWithTooManyLargeBoxesHasNoPage. Against the previous ZplRaster three of them timed out at ten seconds and two failed on the missing refusal. ZplRaster stays at 100% line coverage. One consequence: a version 1 label that painted its page more than sixteen times over would still print and would not be previewed.

B. Nothing tested that the version 1 print request is unchanged. VersionOneJobFrameTest drives render() and compares the frame the bridge sends with a golden captured from the released v1.1.0-RC.2 (commit e9ffba4) in a temporary worktree; compat-v1/job-frame-render.md records the tag, the inputs and how to reproduce the capture. Today's frame equals the released one. One caveat, stated plainly: the released bridge writes the seven members of stock from an unordered map, so four captures at the tag gave four files differing only in the order of those members. The test therefore compares every byte outside stock exactly and the seven members as the same texts in any order. The bridge was not changed to fix an order. Perturbing the request builder (swapping two fields, renaming a key, adding a field) made the test fail each time. VersionOneAnswerTableTest (first pushed as ReleasedRendererAnswerTest) is a table of 71 answers to a version 1 job, accepted and rejected, each through render(). The answers are synthetic, built by the test's fake worker in the shape renderer 0.1.0 answers with; the expected outcomes are today's bridge's, and were not run at the released tag. Only the job frame golden was captured there.

Javadoc and the version question. The comments on IsolatedRendererBridge no longer claim that nothing of a version 2 scene reaches a renderer that cannot draw it: after an image rollback ONE job can, and is refused as RENDERER_TOO_OLD. Any job that ends in RENDERER_TOO_OLD now clears what the bridge remembered, so the next asks the version first. The rollback scenario itself already behaved that way (a 0.1.0 answer cleared the cache); the gap was a 0.2.0 renderer naming the refusal itself, which VersionRuleTest.anyJobThatEndsAsTooOldMakesTheNextJobAskTheVersion covers and which failed before the change.

Tests

  • DocumentPreviewTest (foundation, new; 33 tests after the review): sample, longest values and record data; the context equals the one generation delivers for the same record; a record the person cannot read; the permission matrix; no row, audit event or file written; process state after a finished action; PDF refused as "Preview is available for labels only."; a refused scene (boxes page and the list); a version 1 label (exact=false, PRINTER_FONT_NOT_SHOWN); bounds; the log line; help coverage; instance validation of the new metadata; the composite's JSON shape.
  • LayoutPreviewServiceTest (bridges/renderer, new): through the service and the real bridge, the preview of a version 2 scene has the hash and size of the bytes the print path returns for the same job, at 203, 300 and 600 dpi; a version 1 label is not exact.
  • GenerationRegistrationHooksTest and GenerationProcessesTest updated deliberately for the new wiring and the relabel.
  • The version 1 corpus is untouched.

Changes after the independent review

  1. Only a publication is previewed by revision. Guarantee: a person who may not author a template cannot obtain the picture of an unpublished draft, with sample data or with a record, by naming a revision. DocumentPreviewService resolves a named revision through the new DocumentDefinitionService.publishedSnapshot, which answers any row whose purpose is not publication (the frozen draft of a retained preview is PREVIEW) with the QNotFoundException "Document definition not found." of an identity that does not exist. It is the rule GenerationWarmInventory applies, including that a row written before revisions recorded a purpose is a publication. It holds for the service, for the action before and after its form, and for the widget. Test aNamedRevisionThatIsNotAPublicationIsNotFound: a real frozen draft and rows of purpose DRAFT, RETIRED, SUPERSEDED and published, beside an identity with no row, named by a person with view, with view and generate, with every permission, and with generate alone; the type, the message and the widget's whole answer are equal for all of them; a configure holder still previews the draft through the template; the current publication, an earlier one and an unmarked legacy row are still previewed with view.
  2. The action's widget request carries no record identity. QQQ's widget route logs the parameters of a widget request that fails or takes over a second (inputParams), and a client sends every plain process value as a parameter, so the record's identity and the sample name were logged for a slow preview. Now the check step moves the accepted choices of the form into one structured process value, and the widget, given the run's processUUID, reads them with RunProcessAction.getStateForUser (QQQ answers only the session that owns the run, while it may still run the action). The widget no longer reads a revision, record, sample, stock or resolution from any request parameter. Tests aFinishedActionKeepsOnlyTheChoicesAndItsWidgetRequestCarriesNoRecord (the request, built as a client builds it, is exactly processUUID, previewTemplateId, sourceTable; the picture is still drawn from the chosen record), theWidgetRequestOfTheActionCarriesNoSampleName, theWidgetOfTheActionReadsItsChoicesFromThePersonsOwnRun (another session is denied; an unknown or malformed run, or one whose form was not accepted, is one fixed refusal; choices sent as request parameters are not read).
  3. Cache-Control. The widget route sets none and a widget renderer cannot set response headers, so this is documentation: README, "What a host must configure for label previews" (send no-store for widget responses; what the request log holds). The framework change is under Upstream below. No code.
  4. Too dense to preview is not "cannot print". When the only thing refused is the picture (PREVIEW_TOO_LARGE and no other refusal) the widget's verdict is "This label is too dense to preview; printing is not affected." (or "too large", for a page of more than sixteen million dots) in place of "This layout cannot print."; a renderer adapter that throws PREVIEW_TOO_LARGE gets the same sentence. Test aLabelTooDenseToDrawIsSaidToPrintAllTheSame.
  5. A placeholder of no area draws nothing. ZplRaster.hatch returns at once for no width or no height; the guard its one caller had is now in the painter. Stated plainly: the reported case, an empty printer font text field, did not reach hatch before this change either, because such a field is refused as RASTER_FIELD (and by the output policy as ZPL_PLACEMENT) first; the existing test for that is unchanged. Test ZplRasterTest.aPlaceholderOfNoWidthOrNoHeightDrawsNothing (it fails with the guard removed). ZplRaster stays at 100% lines.
  6. The answer table is named for what it is. ReleasedRendererAnswerTest is now VersionOneAnswerTableTest, and its header says that its answers are synthetic and its 71 outcomes are today's bridge's, not captured at v1.1.0-RC.2. It was not re-captured at the tag.
  7. "Longest values" is indicative. The help of the data field and of its choice list now says that the picture is drawn with capital W, that W is not the widest character of every font, and that the renderer's proof at publication decides. Checked against the unmerged text package (branch feat/scene2-text, read, not depended on): its maxLength proof (that many of the face's worst-case glyph must fit, else TEXT_DOES_NOT_FIT) runs only when the operation is VALIDATE, which is publication. A preview is a render job, so it fits the W values it was given and proves nothing about other values. A longest values picture that looks right can therefore still be refused at publication; the help text and the javadoc now say where the proof is. Test theHelpSaysThatLongestValuesAreIndicative.
  8. Parity with printing. Before a source record is read, the preview now asks what the print path asks of the source table (GenerationSecurity.requireRead: the channel and credential ceilings), in check and in preview, and again with the read after the context script. Test aRecordIsReadOnlyWithinTheChannelAndCredentialCeiling (a channel denied the source table, and a credential scoped away from it, are refused with the ceiling's own denial before any script or renderer runs; sample data is still drawn).

Not changed, and said so: Preview.printable is still false in the bridge's answer for a label that is only too dense to draw (the widget's verdict is what changed). An earlier publication that a later one replaced is still a publication and is previewed with view; the data model has no retired state.

Upstream

For the framework (QQQ), in neutral terms:

Widget routes should send Cache-Control: no-store. The widget route (GET and POST /widget/{name}, QJavalinImplementation.widget) writes the widget's data as the response and sets no Cache-Control header. Widget data is produced per person and can hold record data: a child record list, a statistic, an image block carrying a data URL. A widget renderer has no access to the response, so neither a QBit nor an application can set the header from a renderer; today the only place is a filter or a proxy in front of the application. Proposed: the widget routes set Cache-Control: no-store on every answer, success or error, with no configuration needed.

Related, same route: when a widget call fails or exceeds the slow threshold, the route logs every request parameter (inputParams, in logEndSuccessIfSlow and logEndFail). Parameters can identify records, and the standard clients send every plain process value as a parameter when a widget is shown in a process step. Proposed: log the parameter names only, or let widget metadata name the parameters that may be logged.

Read in the source of QQQ 4.1.0-RC.26; not observed against a running server.

Build

./mvnw --batch-mode --no-transfer-progress -s <settings> -T1C verify on head 363e96f, run and seen: BUILD SUCCESS, every module, 971 tests, 0 failures, 0 errors, 0 skipped, 0 Checkstyle violations, every JaCoCo check met (the three preview classes of the foundation and ZplRaster at 100% lines). No gate was skipped. The version 1 corpus is untouched.

Not verified

  • No real browser, no running host, no real renderer image: the renderer in these tests is a recording fake (foundation) or the bridge over a fake worker answering with the committed golden labels (bridges/renderer).
  • How a host's content security policy treats data: images was read in the QQQ source (img-src 'self' data: blob: https:), not observed.

For a host application

Pin the version. There is no schema change in this package (no new table or column). The action, the section, the widget and their help arrive with the QBit's registration; the existing permissions apply.

🤖 Generated with Claude Code

Package **P6b** of `docs/design/LABEL-LAYOUT-FONTS-PREVIEW.md` (sections 6.2 to 6.4, 9 and 10), plus two findings from the independent review of the bridge package. ## Why A person editing a label template must see the exact picture that would print, with its warnings beside it, before anything is printed, from inside the document module's own screens and with no code in the host application. ## What a person sees 1. **On a template's record screen** there is a new section, "Label preview", directly below the overview. It shows the current draft drawn with the first sample the scene declares, at the stock's own resolution: a line saying whether the picture is exact and whether the label can print, the picture, its size in dots and millimetres, the output size and hash, and one line per refusal, warning and note. A person who may only view sees the published revision instead. No record is ever read for this section. 2. **"Preview layout"** is a new action on a template (its current draft) and on a published revision. The form asks for the data (a sample written in the scene, "longest values", or a record), the sample name, the record when one is wanted, optionally another stock, and a resolution (203, 300 or 600 dpi; blank is the stock's own). Submitting shows the same widget drawn from those choices. 3. The existing action is relabelled **"Generate retained preview file"** and is otherwise unchanged (same process name, same behaviour): it stays the way to obtain an audited file, and the only preview of a PDF. ## How - `DocumentPreviewService` (new, public): authorises, loads the draft or the published snapshot without writing anything, builds the context and calls `IsolatedRenderer.preview`. A real record is read as the person (`GetAction` with permissions and record locks enforced) before any context is built; the context is then built exactly as generation builds it, including the pinned context script, and the record is read again after the script as generation does. It logs one line (actor, template or revision id, data kind, dpi, outcome, diagnostic codes) and no content. - `GenerationPreviewWidget` (filled): one composite widget (`IMAGE` block with the page's PNG as a data URL, shown at the label's physical width, between `TEXT` blocks), used by the record screen section and by the action's result step. - `GenerationPreviewProcesses` (filled): the action on the template and revision tables, its two choice lists, and the widget's registration. - Help text for the process, both steps, every field, the section, the widget and the two choice lists is carried by the metadata, as the rest of this repository does. A test walks `HelpTargetCatalog` and requires help on every new target. ## Permissions (no new permission) | Preview of | Data | Needs | |---|---|---| | A draft | Sample or longest values | `document-generation.configure` | | A draft | A record | `configure` and `generate`, and read access to the record | | A published revision | Sample or longest values | `document-generation.view` | | A published revision | A record | `generate`, and read access to the record | A revision that is named must be a publication: the revision table also holds the frozen drafts that the retained preview writes, and such a row (any row whose purpose is not publication) is answered "Document definition not found.", exactly as an identity that does not exist. A draft is previewed through its template alone, which needs `configure`. A record the person cannot read (no table permission, a record lock, or no such record) is refused as "Source record not found." before the script or the renderer runs, and nothing about it is returned. The record selector now asks for `generate` alone (it also asked for `configure`), so a person who may preview a published revision with a record can choose one; every record offered is still read as that person. ## Nothing is stored, including in process state QQQ stores the state of a process after every backend step and never removes it when the process ends: the in-memory provider drops it five hours after the process started, and the shared table provider keeps a row for twelve hours by default. So the picture is never a process value. The result step names the widget; the client requests it by the identifier of the run; the widget reads the person's choices from the state of that run; the request is authorised and drawn like any other; the picture, the values and the diagnostics exist only in that answer. The process state holds the choices only (template or revision id, data source, sample name, source table, record id, stock id, resolution), the form's choices as one structured value that a client does not send as a request parameter. A test runs the whole action with a real record through `RunProcessAction` and asserts the stored state holds no picture, no hash, no context and no value of the record, and that no table gained a row. What reaches the browser is bounded: the first page, a picture of at most 2 MiB (above that, the facts and diagnostics without the picture), and fifty diagnostics with a count of the rest. ## The frontend spike A composite widget with an `IMAGE` block fed by a data URL renders as an `<img>` in the frontend a host serves (checked against release `v1.0.0-RC.39` of `qqq-frontend-next`): seeded in a process step, fetched in a process step, and in a record screen section naming the widget. This was run as throwaway component tests in a simulated browser document (jsdom) with the payload shape the backend block classes serialise to, which a test in this repository asserts. It was **not** run in a real browser against a running host, and the design's "one browser check in the host's local stack" is not done. The `FILE_PREVIEW` fallback was not needed. No frontend change. ## Where the code differs from the design (also recorded in the design, section 6.4) - The result step **fetches** the widget instead of being seeded from a process value, for the retention reason above. - No draft fingerprint on the service: nothing is saved against a preview. - The scene schema allows sixteen samples where the design says eight; the schema is followed. - "Longest values" on the host uses a capital W repeated to each declared `maxLength`: the host has no fonts and cannot know a face's widest glyph. W is not the widest glyph of every face, so the picture is indicative and the form's help says so. The renderer's validation at publication remains the proof of fit. - A version 1 scene has no `samples`, so its sample preview has an empty context. - This repository has no help bundle file; help is on the metadata. ## Review findings on the bridge **A. Preview painting could burn host CPU on a hostile or broken worker's output.** `ZplRaster` painted a box and a placeholder by testing every dot of the field's area, and the output policy allows 6000 fields. Now every painter counts the dots it is about to paint, an overlap each time, and the field that would take the page past sixteen times its dots (the renderer's own limit for a scene) is refused as `PREVIEW_TOO_LARGE`, which `preview()` returns as a result (`printable=false`, no page, `limit.paintedPercent` 1600). A box is one fill or four non-overlapping bars written whole bytes at a time; a placeholder is a one-dot outline with its two diagonals (its look changes: the dense diagonal hatch is gone, and three existing expectations were updated for that). A refused scene whose boxes would pass the budget has no page and gains the diagnostic. Tests: `ZplRasterTest.thousandsOfPageSizedBoxesAreRefusedOrCostOnlyTheirInk`, `thePaintedBudgetIsExactForEveryPainter`, `theRefusedPageIsBoundedHoweverManyBoxesItHas`, `aBoxIsTheSameDotsAsPaintingItDotByDot` (11,336 boxes against a per-dot reference; it passes before and after by design), `PreviewTest.aLabelThatWouldPaintTooMuchIsRefusedAsAResult`, `aRefusedSceneWithTooManyLargeBoxesHasNoPage`. Against the previous `ZplRaster` three of them timed out at ten seconds and two failed on the missing refusal. `ZplRaster` stays at 100% line coverage. One consequence: a version 1 label that painted its page more than sixteen times over would still print and would not be previewed. **B. Nothing tested that the version 1 print request is unchanged.** `VersionOneJobFrameTest` drives `render()` and compares the frame the bridge sends with a golden captured from the released `v1.1.0-RC.2` (commit `e9ffba4`) in a temporary worktree; `compat-v1/job-frame-render.md` records the tag, the inputs and how to reproduce the capture. Today's frame equals the released one. One caveat, stated plainly: the released bridge writes the seven members of `stock` from an unordered map, so four captures at the tag gave four files differing only in the order of those members. The test therefore compares every byte outside `stock` exactly and the seven members as the same texts in any order. The bridge was not changed to fix an order. Perturbing the request builder (swapping two fields, renaming a key, adding a field) made the test fail each time. `VersionOneAnswerTableTest` (first pushed as `ReleasedRendererAnswerTest`) is a table of 71 answers to a version 1 job, accepted and rejected, each through `render()`. The answers are synthetic, built by the test's fake worker in the shape renderer 0.1.0 answers with; the expected outcomes are today's bridge's, and were not run at the released tag. Only the job frame golden was captured there. **Javadoc and the version question.** The comments on `IsolatedRendererBridge` no longer claim that nothing of a version 2 scene reaches a renderer that cannot draw it: after an image rollback ONE job can, and is refused as `RENDERER_TOO_OLD`. Any job that ends in `RENDERER_TOO_OLD` now clears what the bridge remembered, so the next asks the version first. The rollback scenario itself already behaved that way (a 0.1.0 answer cleared the cache); the gap was a 0.2.0 renderer naming the refusal itself, which `VersionRuleTest.anyJobThatEndsAsTooOldMakesTheNextJobAskTheVersion` covers and which failed before the change. ## Tests - `DocumentPreviewTest` (foundation, new; 33 tests after the review): sample, longest values and record data; the context equals the one generation delivers for the same record; a record the person cannot read; the permission matrix; no row, audit event or file written; process state after a finished action; PDF refused as "Preview is available for labels only."; a refused scene (boxes page and the list); a version 1 label (`exact=false`, `PRINTER_FONT_NOT_SHOWN`); bounds; the log line; help coverage; instance validation of the new metadata; the composite's JSON shape. - `LayoutPreviewServiceTest` (bridges/renderer, new): through the service and the real bridge, the preview of a version 2 scene has the hash and size of the bytes the print path returns for the same job, at 203, 300 and 600 dpi; a version 1 label is not exact. - `GenerationRegistrationHooksTest` and `GenerationProcessesTest` updated deliberately for the new wiring and the relabel. - The version 1 corpus is untouched. ## Changes after the independent review 1. **Only a publication is previewed by revision.** Guarantee: a person who may not author a template cannot obtain the picture of an unpublished draft, with sample data or with a record, by naming a revision. `DocumentPreviewService` resolves a named revision through the new `DocumentDefinitionService.publishedSnapshot`, which answers any row whose `purpose` is not publication (the frozen draft of a retained preview is `PREVIEW`) with the `QNotFoundException` "Document definition not found." of an identity that does not exist. It is the rule `GenerationWarmInventory` applies, including that a row written before revisions recorded a purpose is a publication. It holds for the service, for the action before and after its form, and for the widget. Test `aNamedRevisionThatIsNotAPublicationIsNotFound`: a real frozen draft and rows of purpose `DRAFT`, `RETIRED`, `SUPERSEDED` and `published`, beside an identity with no row, named by a person with `view`, with `view` and `generate`, with every permission, and with `generate` alone; the type, the message and the widget's whole answer are equal for all of them; a `configure` holder still previews the draft through the template; the current publication, an earlier one and an unmarked legacy row are still previewed with `view`. 2. **The action's widget request carries no record identity.** QQQ's widget route logs the parameters of a widget request that fails or takes over a second (`inputParams`), and a client sends every plain process value as a parameter, so the record's identity and the sample name were logged for a slow preview. Now the `check` step moves the accepted choices of the form into one structured process value, and the widget, given the run's `processUUID`, reads them with `RunProcessAction.getStateForUser` (QQQ answers only the session that owns the run, while it may still run the action). The widget no longer reads a revision, record, sample, stock or resolution from any request parameter. Tests `aFinishedActionKeepsOnlyTheChoicesAndItsWidgetRequestCarriesNoRecord` (the request, built as a client builds it, is exactly `processUUID`, `previewTemplateId`, `sourceTable`; the picture is still drawn from the chosen record), `theWidgetRequestOfTheActionCarriesNoSampleName`, `theWidgetOfTheActionReadsItsChoicesFromThePersonsOwnRun` (another session is denied; an unknown or malformed run, or one whose form was not accepted, is one fixed refusal; choices sent as request parameters are not read). 3. **`Cache-Control`.** The widget route sets none and a widget renderer cannot set response headers, so this is documentation: README, "What a host must configure for label previews" (send `no-store` for widget responses; what the request log holds). The framework change is under Upstream below. No code. 4. **Too dense to preview is not "cannot print".** When the only thing refused is the picture (`PREVIEW_TOO_LARGE` and no other refusal) the widget's verdict is "This label is too dense to preview; printing is not affected." (or "too large", for a page of more than sixteen million dots) in place of "This layout cannot print."; a renderer adapter that throws `PREVIEW_TOO_LARGE` gets the same sentence. Test `aLabelTooDenseToDrawIsSaidToPrintAllTheSame`. 5. **A placeholder of no area draws nothing.** `ZplRaster.hatch` returns at once for no width or no height; the guard its one caller had is now in the painter. Stated plainly: the reported case, an empty printer font text field, did not reach `hatch` before this change either, because such a field is refused as `RASTER_FIELD` (and by the output policy as `ZPL_PLACEMENT`) first; the existing test for that is unchanged. Test `ZplRasterTest.aPlaceholderOfNoWidthOrNoHeightDrawsNothing` (it fails with the guard removed). `ZplRaster` stays at 100% lines. 6. **The answer table is named for what it is.** `ReleasedRendererAnswerTest` is now `VersionOneAnswerTableTest`, and its header says that its answers are synthetic and its 71 outcomes are today's bridge's, not captured at `v1.1.0-RC.2`. It was not re-captured at the tag. 7. **"Longest values" is indicative.** The help of the data field and of its choice list now says that the picture is drawn with capital W, that W is not the widest character of every font, and that the renderer's proof at publication decides. Checked against the unmerged text package (branch `feat/scene2-text`, read, not depended on): its `maxLength` proof (that many of the face's worst-case glyph must fit, else `TEXT_DOES_NOT_FIT`) runs only when the operation is `VALIDATE`, which is publication. A preview is a render job, so it fits the W values it was given and proves nothing about other values. A longest values picture that looks right can therefore still be refused at publication; the help text and the javadoc now say where the proof is. Test `theHelpSaysThatLongestValuesAreIndicative`. 8. **Parity with printing.** Before a source record is read, the preview now asks what the print path asks of the source table (`GenerationSecurity.requireRead`: the channel and credential ceilings), in `check` and in `preview`, and again with the read after the context script. Test `aRecordIsReadOnlyWithinTheChannelAndCredentialCeiling` (a channel denied the source table, and a credential scoped away from it, are refused with the ceiling's own denial before any script or renderer runs; sample data is still drawn). Not changed, and said so: `Preview.printable` is still `false` in the bridge's answer for a label that is only too dense to draw (the widget's verdict is what changed). An earlier publication that a later one replaced is still a publication and is previewed with `view`; the data model has no retired state. ## Upstream For the framework (QQQ), in neutral terms: > **Widget routes should send `Cache-Control: no-store`.** The widget route (`GET` and `POST /widget/{name}`, `QJavalinImplementation.widget`) writes the widget's data as the response and sets no `Cache-Control` header. Widget data is produced per person and can hold record data: a child record list, a statistic, an image block carrying a data URL. A widget renderer has no access to the response, so neither a QBit nor an application can set the header from a renderer; today the only place is a filter or a proxy in front of the application. Proposed: the widget routes set `Cache-Control: no-store` on every answer, success or error, with no configuration needed. > > Related, same route: when a widget call fails or exceeds the slow threshold, the route logs every request parameter (`inputParams`, in `logEndSuccessIfSlow` and `logEndFail`). Parameters can identify records, and the standard clients send every plain process value as a parameter when a widget is shown in a process step. Proposed: log the parameter names only, or let widget metadata name the parameters that may be logged. > > Read in the source of QQQ 4.1.0-RC.26; not observed against a running server. ## Build `./mvnw --batch-mode --no-transfer-progress -s <settings> -T1C verify` on head `363e96f`, run and seen: BUILD SUCCESS, every module, 971 tests, 0 failures, 0 errors, 0 skipped, 0 Checkstyle violations, every JaCoCo check met (the three preview classes of the foundation and `ZplRaster` at 100% lines). No gate was skipped. The version 1 corpus is untouched. ## Not verified - No real browser, no running host, no real renderer image: the renderer in these tests is a recording fake (foundation) or the bridge over a fake worker answering with the committed golden labels (bridges/renderer). - How a host's content security policy treats `data:` images was read in the QQQ source (`img-src 'self' data: blob: https:`), not observed. ## For a host application Pin the version. There is no schema change in this package (no new table or column). The action, the section, the widget and their help arrive with the QBit's registration; the existing permissions apply. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
james.maes added 5 commits 2026-11-12 19:57:38 +00:00
A person editing a label template sees the picture that would print, with
its refusals and warnings, before anything is printed, from the module's
own screens and with no host code.

- DocumentPreviewService: authorises by the existing permissions (a draft
  needs configure, a published revision needs view, a record needs
  generate and is read as the person, with their permissions and record
  locks, before any context is built), loads the draft or the published
  snapshot without writing, builds the context as generation does and
  calls IsolatedRenderer.preview. Data is a sample from the scene, the
  longest declared values, or one record. One log line, no content.
- "Preview layout" action on templates and published revisions: data,
  sample, record, stock and resolution; the result is the preview widget.
- "Label preview" section on the template record screen: the draft with
  sample data (the published revision for a person who may only view).
- The widget is fetched, never seeded from a process value: QQQ keeps the
  state of a finished process for hours (in a table with the shared
  provider), so the picture, the context and the diagnostics exist only
  in the answer to the widget request. The process state holds the
  person's choices only.
- Bounded answer: one page, a picture of at most 2 MiB, fifty diagnostics.
- The existing action is relabelled "Generate retained preview file"; its
  process name and behaviour are unchanged.
- The record selector asks for generate alone, so a person who may
  preview a published revision with a record can choose one.
- Help on every new process, step, field, section, widget and choice list.

Tests: DocumentPreviewTest (data sources, permission matrix, unreadable
record, nothing written, process state, PDF refusal, refused scene,
version 1 label, bounds, log line, help coverage, instance validation)
and LayoutPreviewServiceTest in bridges/renderer (through the service and
the bridge, the preview has the hash of the bytes the print path returns
at 203, 300 and 600 dpi). The registration and process tests are updated
for the new wiring and the relabel. bridges/renderer gains the RDBMS
module at test scope for that test.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
ZplRaster painted a box and a placeholder by testing every dot of the
field's area. The output policy allows 6000 fields, so a label of
page-sized boxes was tens of thousands of millions of dots of work on the
host while an admission slot was held, and the page of a refused scene had
the same cost for each of up to 2048 diagnostic boxes.

- Every painter counts the dots it is about to paint, an overlap each
  time, and the field that would take the page past sixteen times its dots
  is refused as PREVIEW_TOO_LARGE. This is the renderer's own limit for a
  scene. A graphic counts eight dots for each byte.
- A box is the whole area when its border meets itself, else four bars
  that do not overlap, filled whole bytes at a time. The dots are the same
  as before.
- A placeholder (printer font text, a refused element's box) is an outline
  and its two diagonals, so it costs its perimeter and not its area. This
  changes its look: the diagonal hatch every six dots is gone.
- preview() returns the refusal as a result, like the page limit: not
  printable, no page, PREVIEW_TOO_LARGE with limit.paintedPercent 1600. For
  a refused scene it joins the diagnostics the worker sent.

Printing is not changed.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Nothing held the version 1 print request to the released bytes, and the
0.1.0 answers were tested at the units and not through render().

- compat-v1/job-frame-render.json is the frame the bridge of tag
  v1.1.0-RC.2 (e9ffba484e) wrote to its
  exchange for one render() with fixed inputs. VersionOneJobFrameTest
  compiles unchanged at that tag and is its own capture tool; the .md
  beside the frame gives the inputs and the commands.
- The frame is compared byte for byte except for the order of the seven
  members of "stock": the released bridge writes them in the iteration
  order of an immutable Map.of, which the JVM changes from run to run (four
  captures at the tag gave four orders and were otherwise identical).
- ReleasedRendererAnswerTest is one row for each answer of
  qqq-renderer-0.1.0 to a version 1 job, accepted, refused by code or
  rejected, every row through render(), with the name the version rule
  gives the same frame.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The comments said that nothing of a version 2 scene reaches a renderer
that cannot draw it. That is not so: the answer to the version question is
remembered, so after a renderer image is rolled back ONE job reaches the
old (0.1.0) renderer, scene and record values included, and is then
refused as RENDERER_TOO_OLD. The class, field, invoke and probe comments
now say exactly that.

The window stays one job in every case: a job that ends in
RENDERER_TOO_OLD, from render, validate or preview, forgets what was
remembered, so the next job that needs renderer 0.2.0 asks the version
first. An answer from 0.1.0 already did this; a refusal that renderer
0.2.0 names itself, as an error answer or among collected diagnostics, did
not.

FakeWorker can stand for an image that is replaced while a bridge uses it.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
docs(preview): the placeholder is outlined and crossed; record the bridge review findings
maven / build (push) Successful in 12m0s
build / maven (push) Successful in 12m0s
6aba6ce8d4
The widget's sentences, the authoring guide, the bridge README and the
design follow the bounded painting of the preview: a placeholder is an
outline with its two diagonals, and a page painted more than sixteen
times over is PREVIEW_TOO_LARGE. The design also records that the
version question is asked again after any RENDERER_TOO_OLD and that the
released version 1 job frame is pinned.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
james.maes added 2 commits 2026-11-12 20:38:42 +00:00
ZplRaster.hatch returns at once for an area of no width or no height. Its
outline and diagonals would otherwise be placed one dot outside the area:
with no width the diagonal's column divides down to -1 and a stray line is
drawn. The guard that the refused page had around its call is now in the
painter itself, so every caller has it. An empty printer font text field did
not reach the painter before this change either: such a field is refused as
no field (RASTER_FIELD), and by the output policy before that; the existing
test for it is unchanged. ZplRaster stays at 100% lines.

ReleasedRendererAnswerTest is renamed VersionOneAnswerTableTest and its
header says what it is: 71 synthetic answers in the shape the released
renderer answers with, and the outcomes of today's bridge for them. They were
not captured at the released tag, unlike the job frame golden, so the table
holds today's behaviour still and does not show equality with the release.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
fix(preview): only a publication is previewed by revision; the action's widget request carries no record
maven / build (push) Successful in 12m0s
build / maven (push) Successful in 12m0s
363e96f641
Changes after the independent review of the layout preview.

A person who may not author a template cannot obtain the picture of an
unpublished draft by naming a revision. The revision table also holds the
frozen drafts that the retained preview writes; a named revision is now
resolved only when it is a publication, and any other row is answered
"Document definition not found.", with the type and the words of an identity
that does not exist, from the service, from the action before and after its
form, and from the widget. A draft is previewed through its template alone.

The widget of the "Preview layout" action is requested by the identifier of
the run and reads the person's choices from the state of that run. A client
sends every plain process value as a parameter of the widget request, and
the framework's widget route logs the parameters of a request that fails or
takes over a second, so the chosen record's identity and the sample's name
were logged for a slow preview. The check step now holds the accepted
choices as one structured process value, which is not sent, and the widget
no longer reads a revision, record, sample, stock or resolution from any
request parameter.

A source record is read within the channel and credential ceilings the print
path applies to the source table, asked before the record is read.

A label whose picture alone is refused (PREVIEW_TOO_LARGE and no other
refusal) is no longer said to be unprintable: "This label is too dense to
preview; printing is not affected."

The help of the form says that the longest values picture is drawn with
capital W, is indicative, and that the renderer's proof at publication
decides. The README records what a host must configure for label previews:
no-store for widget responses, which neither a widget renderer nor the
widget route sets, and what the request log holds.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
james.maes merged commit 6fc086bb4e into develop 2026-11-12 20:50:56 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: QRun-IO/qbit-document-generation#17