[Feature] Map with arrows #1801
Labels
No labels
accepted
bug
declined
docs
duplicate
enhancement
good first issue
in-progress
needs-info
privacy
security
triage
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
LibreKAT/Ocideck#1801
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem / motivation
When I train people in how a 3D printer works, I want bullet points that point with an arrow to parts of the 3D printer.
Proposed solution
I would like to have bullet points on one side and a graphic on the other. This is already possible. (bear with me)
The graphic is a map, or a product, or a drawing, or a schematic, or... something.
The bullet points end with the start of an arrow pointing to a location on the displayed graphic.
When the bullet point appears, so too does the arrow.
"As a user, I want to be able to add an arrow to a bullet point that points to a location on the displayed graphic"
"As a user, I want to click one time to define where the arrow should point to"
"As a user, I want to be able to have one bullet point point to multiple locations on the graphic.
"As a user, I want to be able to change the location of the end point of the arrow"
"As an entitled user, I want to change how the arrow looks. Color, line width, arrow head size"
Marp / file-format impact
Dunno
Alternatives considered
Make the whole thing in an SVG and use that full screen.
Additional context
I made you a pretty picture, just for fun.
Analysis — feasibility and impact
Thanks for the write-up and for the screenshot; it settled several questions
that the prose alone would have left open (the arrows start at the end of the
bullet text, they cross into the image column, and they may cross each other).
This is an analysis, not a decision and not a promise of code. It is written up
in full so the reasoning behind whatever we decide is on the record. Nothing has
been built or changed.
While analysing, a second scenario was added to the scope by the maintainer:
instead of (or besides) an arrow to a point, a bullet could be linked to a
region of the image, so that region can be lifted out — outlined, spotlit, or
zoomed into. Both are covered below.
1. The request, taken apart
The five user stories read as one feature but are three independent
capabilities, with very different costs:
They are separable and worth separating. Capability 3 is useful on its own
(progressive bullets is a normal presentation feature, and OciDeck does not have
it yet for bullet slides). Capability 1 is useful without the rendering being an
arrow. And the arrow specifically — capability 2 in its most demanding form — is
by a wide margin the most expensive and most fragile of the three.
2. Does it fit the product?
The use case: yes, squarely. "Bullets on one side, graphic on the other" is
already the
bulletsImageslide type, and explaining a machine to a room isexactly what that type is for. Nothing about the request is foreign to OciDeck.
The file format: allowed, but with a known cost. OciDeck's core promise is
that the
.mdstays a normal Marp presentation and that everything specificlives outside the Markdown proper. A coordinate pointing at a pixel is not
expressible in Markdown. There is, however, a settled precedent for exactly this
shape:
<!-- ocideck_image_focus: 0.42,0.31 -->already stores a normalised(0..1) point in the image, as an HTML comment that Marp ignores
(
markdown_service_serialize.dart:12-33,markdown_service_parse.dart:215-244).The annotation layer for live drawing uses the same normalised convention
(
models/annotation.dart). So the mechanism exists and is understood.The price is equally understood: a deck opened in a plain Marp toolchain would
show the text and the picture, and no arrows. That is the same degradation
that image focus, timeline animation and chart rendering already accept. It is
acceptable, but it should be a conscious choice rather than a side effect.
One genuinely pleasant surprise on the reveal. Marpit has supported
fragmented lists since v0.9.0: a list whose items use
*(or1)for orderedlists) is emitted with
data-marpit-fragmentattributes and appears one item ata time. OciDeck currently always writes
-and1.(
markdown_service_helpers.dart:36-72) — but its parser already accepts*,+and1)as list markers (markdown_service_parse.dart:19). Socapability 3 can be stored in pure Marp syntax, with no OciDeck-specific
directive at all, and it would survive into any Marp toolchain's HTML export.
That is a rare case where the interchange-friendly option is also the cheap one.
3. What already exists, so we do not re-invent it
bulletsImagelayout is already aStack— imagePositionedright, text columnPositionedleftwidgets/slides/previews/bullets_image_preview.dart:70-160.mdocideck_image_focus, see §2Scene— a Flutter-free scene model with two backends: aCustomPainterand an SVG emitterservices/scene/scene.dart(incl.sceneToSvg),widgets/slides/previews/scene_painter.dartScene, and the Flutter render (→ preview → PDF/PPTX/ODP) and the HTML export (already used this way for canvas/flow/matrix/tree) both come from the same description.ScenePathexists, so an arrowhead needs no new primitive.TimelineReveal.steps;presenter_navigation.dart:90,135,fullscreen_presenter.dart:857,audience_window.dart:186,320widgets/slides/image_crop_dialog.dartwidgets/slides/image_zoom_dialog.dart(used by question slides)models/annotation.dart,widgets/presentation/annotation_overlay.dart4. The hard part: what is anchored to what
4.1 The image end is easy
A point (or a rectangle) in image space is a normalised value that never moves:
it does not care about font size, slide size, text reflow, or which export you
run. It is the same problem
ocideck_image_focusalready solved. Low risk.4.2 The text end is the expensive one
The screenshot shows arrows starting at the end of the bullet's text. That
is a laid-out position, and in OciDeck it is a position that is computed at
render time and is different on every surface:
_imageBulletsScale(...)inbullets_image_preview.dart, withfitScaleOverridewhen the slide is partof a split run (
services/split_run.dart). The end-of-line x dependson that scale.
LayoutBuilder+ClipRect+OverflowBox, so thearrow must be painted in the outer stack, not in the clipped column.
(
listStyle == ListStyle.richText) — a second, different anchoring problem.Two ways to get that position, both with a real drawback:
services/slide_layout_metrics.dart). Cheap to write, and it rots silently:an estimate that predicts a render dimension drifts out of agreement with the
renderer and nothing fails loudly. This project has been bitten by exactly
that before.
RenderObject/CustomMultiChildLayout.Correct, more work. It must not be a one-frame-late overlay: PDF/PPTX/ODP are
produced by capturing a single rendered frame of the real preview
(
services/slide_rasterizer.dart,export_service.dart:548-551), so anoverlay that paints a frame later would be missing or stale in every
exported deck while looking fine on screen.
And then it has to be done a second time for the HTML export (measure in the
browser after
markedhas rendered — there is already a render script to hookinto,
marp_html/marp_html_service_render_script.dart), and a third time forLaTeX/Beamer, or dropped there.
This is the single biggest cost driver in the whole request, and it exists
only because the arrow starts at the text. An overlay that lives entirely
inside the image has none of these problems.
4.3 The reveal
Per-click stepping exists only for timelines. Extending it to bullet slides
touches the presenter, the beamer payload, the audience window, rehearsal and
auto-advance — the same handful of places
_timelineStepappears today, whichis a known and bounded list. Exports flatten it (everything shown), which is
also what Marp does with fragmented lists in PDF.
5. File format
Bullets are a plain
List<String>(Slide.bullets). Every existing piece ofper-item metadata is stored inline in the item's own string: tab characters
for indent level,
[x]for checklist state, a private-use codepoint for groupheadings,
[label](#anchor)for menu blocks (models/slide_bullets.dart,services/menu_blocks.dart).That is not a stylistic preference, it is load-bearing. The bullet list is
rewritten wholesale in several places:
bulletsImageslide is split over several pages, eachrepeating the image (
services/split_run.dart).ocideck_view_limitreplaces the list with a shortenedone plus a count caption (
services/display_window_service.dart:410-425).Anything stored as a separate index-keyed list ("bullet 3 points at 0.4,0.2")
breaks in all three cases. Anything stored inline travels with its bullet for
free. So:
Option A — inline, per bullet (recommended).
- printing head <!-- ocideck_point: 0.62,0.28 -->or, for a region,<!-- ocideck_region: 0.55,0.20,0.18,0.22 -->. Survives reorder, delete,splitting and windowing without a line of extra code.
Option B — a per-slide directive keyed by bullet index. Simpler to parse,
and wrong for the three reasons above.
Option C — a sidecar file. Wrong by the project's own boundary rule:
readable values about the content belong in the
.md; sidecars are for opaquedata and for things about the document. A coordinate a human can read and
hand-edit is content.
One forward-compatibility hazard worth stating explicitly, because it is the
kind of thing that is cheap to design around now and impossible to fix later. An
older OciDeck build reading a newer file:
up as literal text inside the bullet. Ugly in the editor, but it round-trips
and nothing is lost.
(
markdown_parse/markdown_service_parse_directives.dart:259-262,_isTailNote) and is swallowed into the presenter notes. On save it iswritten back as a note. The link is silently lost and the notes are
polluted.
That is fixable — for example by never emitting the marker as the last thing in
the block, or by giving the directive a shape the tail-note test skips — but it
has to be decided before the first file is written, not after.
6. What each surface would need
Scene+ painter.split-imagediv — pure CSS percentages, no JS\tikzmark), realistically: skipThe LaTeX export already drops the focal point, the zoom and the caption on a
bulletsImageslide (services/latex/beamer_slide_builder.dart:175-191), sodropping an overlay there is consistent with the existing contract rather than a
new hole — as long as it is written down.
Note how sharply the two columns differ. An overlay that stays inside the
image is nearly free on six of the eight surfaces. An arrow that starts in the
text is expensive on all of them.
7. Accessibility and legal
An arrow or a highlight is information carried by position and colour alone. For
a screen-reader user, or in a printed handout in greyscale, "printing head →"
conveys nothing. OciDeck already carries per-image alt text
(
ocideck_image_alt) and already measures contrast of its own chrome, whileexplicitly not claiming WCAG conformance (
docs/ACCESSIBILITY.md).Two consequences:
equivalent is the one that is already text: a numbered marker ("① printing
head") is readable in the alt text and in a black-and-white print without any
extra authoring. An arrow is not.
one story I would push back on hardest. Free colour choice on a small
graphical object is a contrast trap, and OciDeck currently checks contrast
rather than letting authors quietly fail it. A small set of theme-derived
styles is both safer and less work.
8. Three scenarios, with cost
For calibration: adding one simple per-image string field (
imageAltText)touches 21 files in
lib/and 10 test files today. That is the floor for"one new field", before any UI.
S1 — Numbered markers ("callouts") — small
Bullets are numbered; the image gets matching numbered pins at chosen points.
No line crosses the columns.
dialog's interaction model.
Scene→ painter +sceneToSvg; free on PDF/PPTX/ODP; a staticpositioned element in HTML.
number).
S2 — Region highlight — small-to-medium (the added scenario)
A bullet is linked to a rectangle in the image. On reveal: outline it, dim
everything outside it (spotlight), or zoom the image to it.
(
utils/image_focal.dart,Slide.imageZoom), which is what already decideswhich part of a picture is in view.
exactly what a spotlight says, and it does not need the reader to trace a line
across a busy slide.
S1 and S2 are the same feature with two rendering modes over one data model. It
would be a mistake to build them as two features.
S3 — True arrows from bullet text to image point — large
What the screenshot shows.
implementation in browser JS for the HTML export, nothing for LaTeX.
over pages, when a display window hides the bullet, when the text auto-scales
down, when the list is rich text, when a bullet wraps.
cross each other, and the tails start at ragged x positions because the
bullets have different lengths. With four or five bullets this gets messy fast,
and "one bullet → multiple locations" multiplies it.
half of real ones.
Separately: per-bullet reveal — small-to-medium
Worth doing on its own merits, independent of any of the above.
*/1)markers — pure Marp, already parsed (§2). Theonly work is remembering the distinction on read and emitting it on write.
_timelineSteppattern to bulletslides.
does.
9. Impact on the rest of the system
Whichever scenario is chosen, these are touched and are easy to forget:
overlay, and any text an overlay carries (a label) becomes scannable content
for the privacy scanner, exactly as alt text already is
(
services/privacy/privacy_scanner_fragments.dart).deck diff, or two people editing the same slide silently diverge.
finding as an image with no alt text; the analyser should probably say so.
bullets_image_preview.dartis at 527 lines andbullets_image_editor.dartat 633, against a 1000-line ceiling. S3 inparticular would need new files rather than growth in place.
languages besides the Dutch source.
FILE_FORMAT.md(new directive, the known-tokens table, anexample),
USER_GUIDE.md,SOURCE_MAP.md,KNOWN_LIMITATIONS.md(what theLaTeX export and third-party Marp drop),
CHANGELOG.md.check rather than only asserting stored values; this is precisely the kind of
change where the numbers can be right and the picture wrong.
10. Where this lands
The honest summary: the request is feasible and it fits, but the arrow is the
expensive 20% of it and the least robust.
If we build anything here, the shape that survives contact with the rest of the
system is:
Add per-bullet reveal as its own, separate, cheaper piece of work, stored in
plain Marp list markers.
That gives the reporter's actual job — tie this line of text to that part of
the picture, and bring them up together — on every export surface, accessible
by construction, and at a fraction of the risk.
11. Decisions needed before anything is built
acceptable answer to the reporter, or is the arrow itself the point?
written into
KNOWN_LIMITATIONS.md?the first file is written.
the latter, what happens to the contrast check?
Because this touches the file format, it needs a signed-off design round before
any code — that is the standing rule here, and this is exactly the case it was
written for.
My choices:
Design round — the two "work it out before you write a line of code" items
Decisions 1, 2, 5 and 6 are clear and are folded in below. Decisions 3 (can
we prevent the loss in plain Marp / LaTeX, or downscale to something that works)
and 4 (the tail-note hazard) asked for a worked-out robust solution first.
Both are settled here, and both are settled with measurements against the
current build, not with reasoning.
Method note: everything measured below came from throwaway probes run against
main(791c6695). They have been deleted; the working tree is clean and nocode has been changed.
A. What was measured
Four candidate encodings for "this bullet points at this place", each in four
positions: mid-list and last-in-list, on a slide with an image after the bullets
and on a plain bullets slide. Measured: what
parseDeckproduces, and whatsurvives
generateSlide.Atext <!-- ocideck_point: 0.62,0.28 -->bullets=[…, "printing head"],notes="ocideck_point: 0.62,0.28"— the link is severed and the value lands in the presenter notesBtext[](#ocideck-point-0.62-0.28)Ctext <span data-…></span>Dtext [→](#…)So the hazard is real and reproducible, and it is specific to the HTML comment.
It did not fire on a
bulletsImageslide that has an image, because theserialiser writes
</div><div class="split-image">…after the bullets — butthat is an accident of the current layout, not a guarantee, and a
bulletsImageslide with no image writes plain bullets.Second probe — what today's renderer shows, via
stripInlineMarkdown:Bempty linkprinting head— identical to a bullet with no marker at allCspanprinting head <span data-ocideck-point="0.62,0.28"></span>Acommentprinting head <!-- ocideck_point: 0.62,0.28 -->That settles it. The comment is the one option that both loses data and shows
junk. The Markdown link does neither.
B. Decision 4 — solved by not using a comment at all
The encoding is a Markdown link on the bullet, with a reserved href scheme:
Why this is robust, each point measured or verified in the code:
_isTailNotenever sees it because it is not a comment.
byte-identical to an unmarked bullet, because
parseInlineRunsrenders alink's inner text and the inner text here is the label. No junk, no loss,
and it round-trips on save.
(
services/split_run.dart) and display windows(
display_window_service.dart:410-425), because it is part of the bulletstring. That was the whole argument for inline storage.
the
.md" rule (FILE_FORMAT §1). A person with a text editor can move a pin.[label](#anchor)in bullets(
services/menu_blocks.dart).#…links today. Verified:markdown_validator.darthas no link or anchor checking at all, so thisproduces no checker noise;
slide_anchors.dartis slugify/jump helpers only;menuBlocksForis reached exclusively from menu-slide paths.Three conventions ride along:
#ocideck-point:and#ocideck-region:, added tomarkdown_validator_vocabulary.dartso the checker knows them and a futurebuild can tell them apart from a real anchor.
This is not cosmetic — it is what makes decision 3 work. See below.
(
bulletsImage,twoImages,image) and never on amenuslide, whosebullets already mean something else.
And separately, a guard that should ship regardless of this feature. Today's
parser turns any unknown trailing comment into a presenter note. That is a
forward-compatibility hole for every future directive, not just this one. A
one-line change — a comment whose content starts with
ocideck_is a directiveand is never taken as a note — closes the class rather than this instance. Small
standalone PR, useful on its own, and it means builds from that release forward
are safe whatever we do next.
C. Decision 3 — the loss in plain Marp is preventable, and the fallback is legible
Two facts, verified in the code:
themes/<theme>.cssnext to the.md, generated from the bundled Marp themeassets/themes/ocideck.css(services/file/file_service_project.dart:162-179,FILE_FORMAT §1). A third-party Marp CLI therefore renders an OciDeck deck
with OciDeck's own stylesheet.
section.split .split-image { position: relative }(assets/themes/ocideck.css:101).So a pin or a region is pure CSS inside a div that is already positioned, in a
stylesheet we already ship. No JavaScript, no OciDeck. The loss is prevented,
not merely accepted — for rungs 1 and 2.
The degradation ladder
Rung 0 is the reason convention 2 above matters. Because the label is real
Markdown link text, a reader who has only the
.mdstill sees- printing head [2], and the alt text can say what 2 is. The downscale islegible rather than lossy — which is what decision 3 asked for.
For LaTeX, rungs 1 and 2 are the standard TikZ overlay technique (a node placed
on the
\includegraphics). Honest cost:latex_preamble.dartcurrently loadsgraphicxbut nottikz, so this adds one package to everyone's preamble. Itis universally present in TeX Live and MiKTeX, but it is a change to the
preamble and should be a deliberate one. Rung 3 in LaTeX is not proposed.
The one real trap in this, and how it is disarmed
The image is drawn with
object-fit: coverin CSS and the equivalent cover +focal + zoom crop in Flutter. So the visible slot is a crop of the picture, and
a normalised coordinate is not the same thing in slot space and image space.
The coordinate must be in image space — that is the only frame that survives
the author later re-cropping or zooming the picture; a slot-space coordinate
would silently slide off its feature the moment the crop changes.
The naive consequence would be that every renderer re-implements the crop
transform, which is precisely how two render paths drift apart without anything
failing. So:
The pin then inherits the crop for free — no arithmetic in any renderer to keep
in agreement — and it is clipped away automatically when its feature is cropped
out of view, which is the correct behaviour rather than a bug to handle.
D. Decisions 1 and 2 — one model, three renderings
As chosen: all three, with the author picking. Concretely:
{target: point | region, label}, stored inline on thebullet as above. Several links on one bullet covers "one bullet, multiple
locations" with no extra concept.
highlightmode —pin,region, orarrow— deciding howthe links on that slide are drawn. One control, three answers, and the reporter's
request is met without three separate features.
touches the file's meaning, and adding the arrow renderer later costs nothing
in the format.
Label visibility follows the mode: on
pinandregionthe label shows (it isthe whole point), on
arrowit is redundant on screen and is suppressed therewhile staying in the file for rung ≤2. Reversible either way.
E. Decision 5 — reveal, in scope, no debt
Stored as plain Marp list markers:
*for unordered,1)for ordered, whichMarpit reads as a fragmented list. The parser already accepts both
(
markdown_service_parse.dart:19), so this is a serialiser change plus oneper-slide flag, not a format extension. Presenter and beamer follow the existing
_timelineSteppattern (presenter_navigation.dart,fullscreen_presenter.dart:857,audience_window.dart:186). A bullet's links appear with their bullet.Side benefit worth stating: this is the one part of the feature that survives
into a third-party Marp HTML export untouched.
F. Decision 6 — theme-derived styling
No per-object colour, width or arrowhead size. Pin, region and arrow colours are
derived from the theme's existing accent, which puts them inside the contrast
check that already exists rather than beside it. If a theme's accent fails
contrast against a photograph, that is a theme finding and it should be reported
as one — the picture underneath is arbitrary, so a halo or outline in the
surface colour is part of the mark, not an option.
G. Order of work
Each a separate PR, each independently green and useful:
ocideck_*comments are never trailing notes. Standalone,independent value, ships first.
vocabulary,
FILE_FORMAT.md, round-trip tests.lands, PDF/PPTX/ODP get it free from the rasteriser.
dialog, whose stage already mirrors how the slide renders the image.
H. The risk that remains, stated plainly
Rung 3 needs the laid-out end-of-text position, and it must be produced in the
same frame the rasteriser captures (
services/slide_rasterizer.dart,export_service.dart:548-551). An overlay that paints one frame later looksperfect on screen and is missing from every exported deck. That is a
CustomMultiChildLayout/customRenderObjectjob, not a post-layout overlay,and it is the one part of this that needs a test against a real rendered
frame rather than an assertion about stored values.
Rungs 1 and 2 carry none of that risk, which is why they come first.
Independent review and corrected solution design
The use case is clear and fits OciDeck: an author needs to tie an explanation to one or more parts of an image and, optionally, reveal the explanation and its visual references together.
The review outcome is more cautious than the design above. The feature is feasible, but the current format proposal is not ready to implement. Several claims in the previous design round do not hold against the current code or a real Marp CLI render. This comment records the corrections first and then gives a replacement design.
Nothing has been built or changed. The review was performed against
mainat791c6695, with separate product, Markdown/interchange, architecture, usability, test and accessibility checks.Analysis and fact-check
What is confirmed
bulletsImageis the right existing slide type for this job.*and1)markers, and Marp HTML can reveal them incrementally.Corrections to the previous design
1. The proposed plain-Marp CSS fallback does not work
A real Marp CLI 4.5.0 probe showed two separate problems:
theme: ocideckdoes not automatically load a neighbouringthemes/ocideck.css; the CLI needs an explicit theme-set option.[1](#ocideck-point:0.55,0.21)remains an<a>in the text column. CSS cannot split the two numbers out of thehref, create a target in a sibling image container, or apply the image crop transform to it.Plain Marp can therefore be promised only a meaningful text fallback, not a pin or region overlay, unless the Markdown carries real portable overlay markup or requires an explicit plugin/script. Neither should be assumed silently.
2. A reserved Markdown link is not neutral metadata
The bytes of
[1](#ocideck-point:...)survive a current parse/save round trip, but older OciDeck and generic Markdown renderers correctly expose it as a link. It is styled and announced as a link, has no real destination, and becomes a dead\\hrefin LaTeX.Adding the prefix to
markdown_validator_vocabulary.dartwould not fix this: that vocabulary contains class and comment directives, not href schemes. Callouts need their own codec and validator.3. The proposed one-line trailing-comment guard is insufficient
Preventing an unknown
ocideck_*comment from becoming presenter notes only removes one loss path. A typed slide can still discard the unmodelled comment when it is saved. The robust carrier must use a preservation path that a released older reader demonstrably writes back unchanged.The format may use a repeatable underscore directive, but only after a real new-reader -> v0.4.10 save -> new-reader test proves it. This is a format-v2 decision, not a parser patch discovered during implementation.
4. Marp fragment markers are an export projection, not durable author state
OciDeck accepts
*and1)while parsing, but does not retain the exact marker and writes the list back as-or1.. A save in an older reader therefore silently removes reveal behaviour.Reveal must have canonical, older-reader-preserved metadata. The writer may additionally emit Marp fragment markers as a portable HTML projection.
5. Image overlays do not inherit crop and zoom automatically
In both Flutter and CSS,
covertransforms the image pixels inside the image element. An overlay sibling or wrapper child does not receive the same source-image transform for free. The currentimage_focal.dartreturns an alignment; it is not a complete source-image-to-visible-slot mapping.Without one shared geometry contract, a mathematically valid callout can point to the wrong component after focal-point, zoom, aspect-ratio or crop changes.
6. OciDeck HTML is a separate renderer
The self-contained HTML export assembles its own structural CSS and markup. Adding a rule to
assets/themes/ocideck.cssdoes not add callout DOM or callout CSS to that export. Flutter, OciDeck HTML and LaTeX each need an explicit renderer adapter over the same typed model.7. TikZ is already present
The Beamer preamble already loads TikZ and pgfplots. No new package is required for a pin or region overlay, but the overlay still needs an implementation and a real compile-and-render test. LaTeX currently drops focal point, zoom and crop on
bulletsImage, so visual parity cannot be claimed yet.8. Numbered pins are not “accessible by construction”
A number reduces dependence on colour and crossing lines, but it does not tell a screen-reader user which part of the picture is meant. Every target needs a human description and a programmatic relationship to its bullet. Theme-derived colour also does not guarantee contrast against arbitrary photograph pixels; the mark needs a non-optional two-tone edge or halo.
The current PDF/PPTX/ODP exports remain one raster image per slide. They must not be described as structurally accessible merely because the callout is visible.
9. Collaboration has a prerequisite defect
imageZoomis present in whole-slide collaboration snapshots but absent fromSlideFieldand ordinary deck diffs. Two collaborators can therefore already end up with different image crops. A future callout could point to different content for each participant even when its own coordinates match.The collaboration field registry and its tests must be repaired before callouts are introduced. The same complete chain is then required for callouts and reveal: whole-slide codec, field operations, diff, apply, reconnect snapshot and protocol capability negotiation.
10. Coordinate precision touches privacy scanning
Four-decimal pairs such as
0.6200,0.2800can resemble latitude/longitude to the current privacy rules. Canonical callout coordinates should use bounded precision, and the scanner should understand the callout carrier rather than scanning its geometry as prose. Human target descriptions remain normal scannable content.Replacement solution
1. Product boundary
Build a bounded image callout feature for
bulletsImage, not a general diagram or connector editor.In the first supported scope:
Do not add a new slide type, arbitrary connector paths, manual bend points, per-target modes, free colours, line widths or arrowhead sizes.
2. Format-v2 contract
Separate the visible fallback from the OciDeck data.
〔A〕. It remains useful in a text editor, generic Markdown renderer and plain Marp._classtoken.The exact directive grammar must be frozen only after the v0.4.10 cross-version probe proves that the proposed carrier survives both an unchanged save and an unrelated edit.
ocideck_formatthen increases monotonically to version 2.The typed model separates:
CalloutTarget: point or region in image space;ImageCallout: stable reference, one or more targets andtargetDescription;CalloutPresentation: pin, region highlight or arrow;BulletRevealMode: all-at-once or steps.Geometry and presentation must not be one enum: a region is data; outline, spotlight and arrow are ways to render data.
3. One geometry contract
Add a Flutter-free
ImageViewportGeometryservice. Its inputs are intrinsic image size, visible slot, fit, zoom, focal point and target geometry. Its outputs are the painted image rectangle, mapped target geometry and clipping state.Flutter, HTML and LaTeX use the same test vectors. An author may place a target only on the currently visible crop. If a later recrop, zoom or width change moves it out of view, saving is blocked with a recovery choice: adjust the image, zoom out, move the target or remove it.
4. Renderer contract
True arrows come last. Use a fixed aligned connection rail at the right side of the bullet row, not the last glyph. This still connects the bullet to the image while avoiding ragged tails, wrapped-line ambiguity and a fragile post-layout measurement. Crossing arrows produce a quality warning and a suggested switch to pins; OciDeck does not grow a manual route editor.
5. Author interaction
On
Bullets + image, add oneCalloutssection and anEdit callouts...action.The editor shows the real slide layout and crop:
Add another targetbefore an extra click can create another target.All functions also need keyboard controls and a non-drag path. Each target is focusable and named by bullet reference and target number. Styling is theme-derived but includes a fixed contrasting halo/edge; it is not user-customisable.
6. Reveal architecture
Do not copy the existing timeline-only step state. Introduce a headless
PresentationStepPlanused by both timelines and bullet callouts. Presenter and audience receive the same generic step value.With reveal enabled:
The serializer can emit
*or1)for Marp HTML, but the durable reveal intent comes from the format-v2 directive.7. Order of work
Each vertical slice must be independently green and must not expose an unfinished public format:
imageZoom, and add a registry/parity gate.ImageViewportGeometryand its shared renderer vectors.8. Mandatory acceptance gates
Before this issue can be called complete, the implementation must prove:
imageZoomdiff;Decision
The request is a product fit, but the current design should remain design-blocked until the format-v2 grammar and cross-version preservation proof are approved. Once those two items are demonstrated, the issue can move to
acceptedand implementation can start with the collaboration prerequisite and the pin vertical slice.This is not a request for more explanation from the reporter; the remaining information is an internal OciDeck design obligation.
Design v2 — reworked after the review
The review is largely right, and where it is right it is right about things that
would have cost a released format. I took each of the ten corrections and tried
to break it rather than agree with it. Eight hold, one is right in a way that
turns out to be worse than stated, one is right but has a cheaper fix than
proposed, and the review has one factual slip of its own. My own previous round
had four errors; they are named below.
Everything here was measured against
main(791c6695) with throwaway probesand a real
marpCLI (v4.4.0 / marp-core v4.3.0). All probes have been deleted;the working tree is clean and no product code has been changed.
1. Verification of the review
themes/ocideck.csshrefstyleimageZoomcollaboration gap1.1 The theme is not loaded — and the loss is bigger than callouts
Rendered
theme: ocideckwith a realmarp deck.md -o out.html, withthemes/ocideck.csssitting next to the deck exactly as OciDeck writes it. Theoutput carries
@theme defaultand contains nosection.splitCSS at all.So my previous claim — "a third-party Marp CLI renders an OciDeck deck with
OciDeck's own stylesheet" — was wrong. It only holds with an explicit
--theme-set themes/ocideck.css, and then it does hold: the split CSS loads andthe layout comes back.
This is bigger than this issue. Without that flag, a plain Marp render of any
OciDeck split slide already loses the whole two-column layout today. That is a
pre-existing interchange fact worth writing into
KNOWN_LIMITATIONS.mdregardless of what we decide here.
1.2 Marp strips inline
style, but keepsclassNew finding, not in the review. Rendering
<span class="ocideck-pin" style="left:62%;top:28%">A</span>through plain Marpyields
<span class="ocideck-pin">A</span>— the class survives, the inlinestyle is sanitised away,
--htmlor not.Consequence: a coordinate can never travel in an inline style attribute. If
plain Marp is to show a pin at all, the position must arrive as a CSS rule. That
is possible, because OciDeck already generates
themes/<theme>.cssper deck(
file/file_service_project.dart:162-179) — one generated class per callout.1.3 There is no position where an unmodelled directive survives
The review said a typed slide "can still discard the unmodelled comment when it
is saved". Measured, and it is worse than that. Parse → save of
<!-- ocideck_callout_a: point 0.62,0.28 -->onmain:split-text, on a typedbulletsImagebulletsslidebulletsslideThere is no position in a slide block where a new comment directive survives a
current save intact. My proposed one-line guard would have fixed only the
fourth row and left the other three losing data silently. That claim was wrong
and the review was right to stop it.
1.4 The crop problem, rendered
Built a calibration image (800×400, a black cross at image-space 0.40, 0.25 and
a white cross at 0.60, 0.75) and rendered two decks through the real Marp CLI.
cross, by roughly 6% of slide width. The measured visible band was image-space
x ∈ [0.302, 0.696] — the cover crop, exactly as the review said.
So correction 6 is confirmed by a picture, not by argument, and my "the pin
inherits the crop for free" claim was wrong for the current markup.
But the fix is cheaper than the review implies for the CSS surface. What
made it land was delegating the cover maths to CSS instead of computing it:
The pins are children of that box and are placed in plain image-space
percentages. The only thing OciDeck has to emit is the image's intrinsic
aspect ratio — a property of the picture, not a cached layout value. Nothing
derived is baked into the file, and one of the three renderers stops having to
reimplement the geometry. (Tested at centre focal, cover, no zoom; focal and
zoom shift the translate and add a scale, and that is what the acceptance gate
below must cover.)
1.5 The remaining confirmations, briefly
exportBaseCss()composes_structuralCss,_reportingCssand_menuCssand never loads the bundled theme asset. A ruleadded to
assets/themes/ocideck.cssdoes not reach it. Confirmed.latex_preamble.dart:311,315loadstikzandpgfplotsin thebeamer preamble. I grepped the article preamble and reported the opposite.
My error; the review is right and no package needs adding.
imageZoomcollaboration gap:SlideFieldcarriesimageSize,imageFocalX/YandimageFocalX2/Y2but notimageZoom(
collab/deck_op.dart), andcollab_deck_diff.dart:194-199mirrors that gap,while
collab_codec.dart:202,279does carry it in whole-slide snapshots.Confirmed, real, and pre-existing. It deserves its own issue whatever happens
here.
privacy_location_rules.dart:125requires four or moredecimals on both sides, and
isPlausibleCoordinate(0.62, 0.28)is true. So0.6200,0.2800would be flagged and0.620,0.280cannot be. The fix is freeand needs no scanner change: bound coordinates to three decimals, which is
exactly what
_formatFocalalready does for image focal points. Follow theexisting convention and the rule can never fire.
number reduces reliance on colour; it does not say which part of a picture is
meant. Every target needs its own description, and the mark needs a
non-optional two-tone edge because the pixels underneath are arbitrary.
2. One correction to the review
The released older reader is v0.4.9, not v0.4.10.
pubspec.yamlsays0.4.10+24, but the newest tag isv0.4.9— 0.4.10 is the current unreleasedline. A preservation proof against "v0.4.10" would have proven nothing about
anything in the field.
And the proof the review asks for as its first gate already exists:
is empty — zero commits, zero changed lines. The parse/serialise/front-matter
path is byte-identical between the released reader and
main, so everymeasurement above is a v0.4.9 measurement. That does not remove the gate; it
means the gate can be answered now, and it was.
3. The format, decided by measurement
Three carriers were tested for surviving a v0.4.9 read-and-save:
〔A〕in the bulletThe last row is not luck: it is rule 1 of the format contract (FILE_FORMAT
§3.0), which promises that keys OciDeck does not own are kept exactly, including
their indented blocks. Measured end to end — parse →
generateDeckreturns thewhole nested block unchanged, in place, with the slide anchor and both bullet
references intact.
So the carrier is split by role, each part on a path that is proven to
survive:
key and the rung-0 fallback. It is ordinary text, so reorder, delete, split
runs and display windows carry it for free, and a text-only reader still sees
which bullet refers to which mark.
decimals, target descriptions, the presentation mode and the reveal intent —
keyed by slide anchor plus reference, never by list index.
overlay — one CSS class per callout plus the
ocideck-imgboxwrapper. Derivedoutput, regenerated on every save from the canonical data, exactly like the
existing
split-text/split-imagescaffolding.ocideck_formatgoes to 2 on first write of a callout.This directly answers the review's §2: the canonical carrier is now a
demonstrated preservation path rather than a proposed one, and the reveal
intent lives there too rather than being inferred from list punctuation.
4. What I adopt from the review unchanged
bulletsImage; no new slidetype, no connector editor, no bend points, no per-target modes, no free
colours or widths.
CalloutTarget(point or region) /ImageCallout(stable reference, targets, description) /
CalloutPresentation(pin, region,arrow) /
BulletRevealMode. Geometry is data; how it is drawn is not.ImageViewportGeometrywith shared test vectors as the singlesource of truth for source-image → visible-slot mapping, used by Flutter,
LaTeX and the editor's hit-testing. The CSS surface delegates to
aspect-ratio(§1.4) and is checked against those vectors rather than reimplementing them.
when a later recrop, zoom or width change would strand a target.
headless
PresentationStepPlaninstead of copying the timeline-only stepstate.
rather than the last glyph, with a quality warning on crossings.
v0.4.9 (and any earlier release still in the field), and the plain-Marp
gate must assert both the missing-theme case and the
--theme-setcase,because they differ.
5. What plain Marp can honestly be promised
marp deck.md〔A〕references. No split layout, no overlay. True today, before callouts exist.marp deck.md --theme-set themes/ocideck.cssArrows are not promised outside OciDeck on any invocation.
6. Order of work
Unchanged from the review, with the prerequisite kept first:
imageZoom, plus a registryparity gate. Separate issue, separate PR, lands first.
in §3 goes into the design doc rather than being re-derived.
ImageViewportGeometryand its shared vectors.7. Still design-blocked, on one thing
I agree the issue should stay blocked, but the block is now narrower than the
review set it. Gate 1 (cross-version preservation of the proposed carrier) is
answered above with evidence. What remains before code is sign-off on the
split-role carrier in §3 — bullet reference plus front-matter block plus
derived markup — because it is a format decision and this repo does not discover
those during implementation.
Two things I would still like decided rather than assumed:
〔A〕renders correctly in Marp but is a CJKbracket and reads oddly in a Latin sentence. A plain
[A]collides withMarkdown link syntax;
(A)and·A·do not. Worth choosing deliberately.§1.3, not a preference. It is the only carrier proven to survive. If that
trade is unacceptable, the alternative is to accept that older builds lose
callouts — which is a legitimate choice, but it must be the stated one.
Design v3 — worked out and frozen
Decisions taken: the visible reference is
(A), and the front matter isaccepted as the canonical carrier. This comment works both out to the level where
implementation can start, and freezes the grammar.
New measurements in this round (probes since deleted, working tree clean):
as gate 1 — see §2.3.
yamlis a dev-dependency only (pubspec.yaml, used by the SBOM tool), solib/has no YAML parser. Promoting it would trip the SBOM gate and a licencereview. The grammar is therefore constrained to what a hand-rolled
line-reader can parse, while staying valid YAML. That constraint shaped §2.
DeckField—deck_op.dartknows onlySlideField.That decides §3.
1. Scope, unchanged from the review
Image callouts on
bulletsImage. One bullet carries at most one reference; areference may have several targets. A target is a point or a rectangle. The slide
picks one presentation over that data: pins, region highlight, or arrows. Reveal
is a separate option. No new slide type, no connector editor, no bend points, no
free colours or widths.
2. Format v2 — frozen grammar
2.1 In the bullet: the visible reference
(+[A-Z]{1,2}+). Uppercase only — which is what keepsmodeandrevealin §2.2 from ever colliding with a reference.parseInlineRunsrenders it as plain text — not a link — including the awkwardcases (
the bracket ] (A),see [docs](guide.md) (A),**bold** (A)).which bullet refers to which mark.
2.2 In the front matter: the canonical data
Grammar, deliberately flat enough for a twenty-line reader and still valid YAML:
modepin|region|arrow. Absent =pin.revealall|steps. Absent =all.[A-Z]{1,2}, unique within the slide.<geometry>[; <geometry>…] | <description><geometry>point <x> <y>orregion <x> <y> <w> <h><description>|; later|are part of itmarkdownYamlScalar, so a description containing:is quoted and stays valid YAMLThree decimals is not arbitrary.
privacy_location_rules.dart:125requires fouror more decimals on both sides before a number pair is treated as a coordinate,
and
isPlausibleCoordinate(0.62, 0.28)is true — so four decimals would makeevery callout a privacy finding, and three cannot. It is also exactly what
_formatFocalalready does for image focal points. Following the existingconvention closes the issue with no scanner change and no new rule.
A callout requires a slide anchor.
ocideck_slide_anchoralready exists, isa known directive, and round-trips. A slide that gets its first callout gets an
anchor via the existing
uniqueAnchor.2.3 Preservation: measured, not assumed
git diff v0.4.9..HEADovermarkdown_parse/,markdown_service_parse.dart,markdown_service_serialize.dartandfront_matter_merge.dartis empty, sothese are measurements of the released reader.
(A)#comment and blank lines around itAlso checked: no owned key (
ocideck_format,paginate) is ever appendedinside the indented block. Gate 1 of the review — "unchanged save and an
unrelated edit" — is answered.
2.4 Version and unknown data
ocideck_format: 2is written on the first save that contains a callout, neverbefore. An unknown
mode/revealvalue, an unknown entry key shape, or amalformed geometry is ignored and preserved byte-for-byte — never clamped
into a different meaning, never dropped. Orphans (a reference with no bullet, or
an anchor with no slide) are kept, reported by the checker, and removed only
by an explicit cleanup action. Silent deletion is the one thing this must not do.
3. The model: front matter is a storage location, not a model shape
Callouts live on the
Slide, not on the deck. The codec is the only thingthat knows they are stored deck-side, keyed by anchor.
This is not tidiness. Collaboration has no
DeckField—deck_op.dartmodelsonly
SlideField— so a deck-level model would need a whole new op class, diffpath and capability negotiation. Keeping callouts on the slide means reorder,
delete, duplicate, undo, split runs, display windows and collaboration all ride
machinery that already exists and is already tested.
Geometry is data; presentation is not part of it. A region drawn as a spotlight
and the same region drawn as an outline are one datum and two renderings.
Collaboration additions:
SlideField.callouts,SlideField.calloutPresentation,SlideField.bulletReveal, each through the full chain — whole-slide codec, fieldop, diff, apply, reconnect snapshot, capability negotiation — plus the registry
parity gate from slice 1.
4. One geometry contract
A Flutter-free
ImageViewportGeometry: given intrinsic image size, slot size,fit, zoom and focal point, it returns the painted image rectangle, the mapped
target geometry and whether the target is clipped away. Flutter, LaTeX and the
editor's hit-testing all read it, and it ships with shared test vectors.
The CSS surface delegates instead of reimplementing, which removes one of the
three places that could drift:
Pins are children of that box and are placed in plain image-space percentages;
CSS computes
coveritself. The only value OciDeck emits is the image'sintrinsic aspect ratio — a property of the picture, not a cached layout
number. Verified by rendering: a pin positioned in slot space misses its target
by ~6% of slide width; the same pin inside this box lands dead centre.
Placement is only allowed on the currently visible crop. If a later recrop, zoom
or width change would strand a target, saving stops with a recovery choice:
adjust the image, zoom out, move the target, or remove it.
5. Renderer contract
exportBaseCss()composes_structuralCss/_reportingCss/_menuCssand never loads the bundled theme, so nothing comes for free heretikzandpgfplotsare already in the beamer preamble (latex_preamble.dart:311,315)--theme-setocideck-imgboxmarkup plus one generated CSS class per callout inthemes/<theme>.css. Marp keepsclassbut stripsstyle, so the position must arrive as a class, never inline(A)only — and note this already loses the whole split layout today, callouts aside(A), plus a readable YAML blockArrows come last and use a fixed connection rail at the right edge of the
bullet row, not the last glyph: no ragged tails, no wrapped-line ambiguity, no
fragile post-layout measurement. Crossing arrows raise a quality finding that
suggests switching to pins. No manual route editor.
6. Author interaction
One
Calloutssection on theBullets + imageeditor, with anEdit callouts…action opening a stage that shows the real layout and crop.Add another targetbefore a further click adds a second target to the samereference.
selected target and Undo restores it.
Replacing or removing the image forces an explicit keep / reposition / remove
choice.
callout.
Everything has a keyboard path and a non-drag path. Each target is focusable and
named by reference and target number. Styling is theme-derived plus a
non-optional two-tone edge, because the pixels underneath are arbitrary and a
theme accent cannot be assumed to contrast with a photograph.
7. Reveal
A headless
PresentationStepPlanshared by timelines and bullet callouts, ratherthan copying the timeline-only step state. With
reveal: steps:The serialiser may additionally emit Marp
*/1)fragment markers as aportable HTML projection, but the durable intent is
reveal:in §2.2 — a markeralone is silently normalised back to
-by any older save.8. Limits, validation and privacy
A–Z)Each is tested at the maximum and at maximum-plus-one. Descriptions are ordinary
scannable content for OciWacht; geometry is not scanned. A redacted slide
(
contentRedacted/mediaRedacted) draws no overlay at all. A callout withouta description is a quality finding of the same class as an image without alt
text — reported, not blocked.
9. Order of work
imageZoominSlideFieldand the diff, plus a registry parity gate. Separate issue, lands first.
preservation evidence in §2.3 goes into the design doc rather than being
re-derived.
ImageViewportGeometryand its shared vectors.10. Acceptance gates
Carried over from the review, with the version corrected and two additions:
description, reveal or unknown-data loss (§2.3 covers the carrier; the gate
must repeat it against a real v0.4.9 build);
without
--theme-set— because they differ;focal and zoom boundaries, against the shared vectors;
opposite corners, to catch a stale overlay;
multiple targets, rich text, keyboard and Undo;
the repaired
imageZoomdiff;at three decimals and no geometry leak around removed media;
greyscale images;
boundaries and collaboration field registration.
11. Status
The design is complete and the grammar is frozen. The two items that had this
issue design-blocked — the carrier and its cross-version proof — are settled and
measured. Implementation can start at slice 1.
Addendum — the design doc, and the values trade-off
The frozen design of the previous comment now also lives in the repository, where
the implementation slices can reference it and where it outlives this thread:
docs/design/IMAGE_CALLOUTS.md— PR#1802.
docs/design/**is deliberately unbundled, so it needs no registration.Gates run before the PR:
make check-static,make check-secrets(gitleaks andtrufflehog, worktree and history),
make sast(semgrep, 0 findings),check_translated_mermaidandtranslate-docs-check— all green.make checkwas run as well; the change touches no Dart.
The trade-off, named
A guardian pass is mandatory here because this touches the file format, and it
found one gap in the design as posted: the values conflict was decided but not
written down. It now is, as §11 of the document, and it belongs on this ticket
too.
The decisive test — if OciDeck stopped existing tomorrow, could the author
carry on? Yes. The bullet says
controller board with display (A)and thefront matter says
A: point 0.402 0.251 | the controller board. Someone with atext editor knows which line refers to which place and what is there, in prose.
Nothing is opaque, nothing is base64, nothing needs OciDeck to decode it. The
overlay can be rebuilt in any tool from what the file already says.
Interchangeability wins over the feature, and the way it wins is the split in
the format. The arrow exists only inside OciDeck. It is allowed to, because it
is a rendering over data that is itself fully portable: whoever leaves keeps
the meaning and loses only the drawing.
What would reverse this decision. If the arrow ever needed geometry of its
own in the file — bend points, custom routing, a hand-placed tail — then what is
stored stops being portable meaning and becomes OciDeck-specific instructions. At
that point the feature does not get built. That is precisely why bend points and
manual routing are out of scope, and why the design uses a computed rail instead
of an author-placed tail.
The visible
(A)is deliberate. It is the one thing this design adds to theprose a foreign reader sees, and it is accepted because it is meaningful to a
human — a callout letter, as any technical manual prints — rather than machine
noise. A hidden carrier would have read more cleanly and would have failed the
test above.
What honestly degrades. Without
--theme-seta foreign Marp render shows nooverlay. Rendered and checked: that invocation already loses the entire
two-column layout today, image overflowing the slide, before callouts exist. The
derived overlay markup neither improves nor worsens that case; it pays off only
in the
--theme-setinvocation, where it is verified to work. The pre-existingloss deserves its own line in
KNOWN_LIMITATIONS.mdon its own account.Next
Implementation starts at slice 1 — the collaboration field parity repair,
including the missing
imageZoom— which is its own issue rather than part ofthis one.
Design landed
PR #1802 is merged;
docs/design/IMAGE_CALLOUTS.mdis on
main. Label moved fromin-progresstoaccepted: the design is settledand the format is frozen, and no implementation slice has started yet.
Green before the merge:
make checkin full (the whole suite, coverage 87.1%,per-file floor clear),
make check-secrets,make sast, and both CI gates onthe PR —
static-gateandscans.This issue stays open until the feature exists. It is the umbrella; the
slices in §9 of the design are the work.
Known prerequisite, found during the design
Slice 1 is not part of this feature and should not wait for it:
SlideFieldcarries
imageSizeand all four image focal fields but notimageZoom, andcollab_deck_diff.dartmirrors that gap, whilecollab_codec.dartdoes carryimageZoomin whole-slide snapshots. Two collaborators can therefore already endup with different image crops today, before any callout exists — and a callout on
a diverged crop would point at different content for each of them.
That is a pre-existing defect with its own consequences and deserves its own
issue and its own regression test, plus a registry parity gate so the next field
cannot be forgotten the same way.
Slice 1 is nu een eigen issue: #1803 — een gewijzigde paneelzoom (
imageZoom) reist niet mee tussen samenwerkende cliënten, en geen enkele poort bewaakt die richting.Twee dingen bleken bij het uitzoeken groter dan gedacht en staan daar uitgewerkt:
imageSizeen alle vier de focal-velden,imageZoomkwam op 12-08-2026 inSlideen heeft nooit indeck_op.dartgestaan.imageZoomis niet de enige. Zestien andere sleutels kent de hele-dia-codec wel enSlideFieldniet. Een deel daarvan is met opzet buiten de synchronisatie gelaten — tabelrijen, annotaties, het zegel — maar aan de code is niet te zien welke bewust zijn en welke vergeten. Dat onderscheid is het eigenlijke defect.Daarom is de reparatie daar niet alleen het veld toevoegen, maar ook een pariteitspoort in de ontbrekende richting: elk veld van
Slidestaat inSlideFieldóf op een expliciete uitsluitingslijst met reden. De bestaande twee pariteitstests kijken allebei de andere kant op, en dát is precies hoe dit erdoorheen kwam.Follow-up review — narrowed after design v3
Design v3 resolves most of the earlier review and is a substantial improvement. Two scope corrections before the remaining points:
KNOWN_LIMITATIONSentry now belong to #1804. They should not expand or block this feature.With those removed, the following points still need a concrete answer before I would treat the format contract as frozen. Slice 1 / #1803 can proceed independently.
1. The release proof names the wrong released version
The repository and Forgejo both contain a published, non-draft v0.4.10 release from 25 August (
faee0d64), before this design round. v0.4.9 is not the newest released reader.The good news is that the relevant parser/serialiser/front-matter diff from v0.4.10 to
791c6695is also empty, so the preservation conclusion still holds. Please change the design and gate from v0.4.9 to v0.4.10 and keep an executable cross-version fixture rather than relying only on the deleted probe. The error does not invalidate the carrier; it does invalidate the claim that the newest released reader was tested by name.2. Canonical front matter and derived persisted output can become inconsistent
§2.3 makes the front-matter block canonical but also persists derived overlay markup in the Markdown body and per-callout position rules in
themes/<theme>.css.A normal hand-edit breaks that relationship:
A: point 0.402 0.251in a text editor;That produces a confidently wrong callout from a file whose canonical data says something else. It is not equivalent to the current split scaffolding: here the same semantic fact is stored both canonically and as a content-specific generated copy.
Please choose one of these contracts explicitly:
.mdalone cannot keep the project render-correct.The latter weakens the “Markdown remains the leading, hand-editable source” promise and needs a guardian decision of its own.
3. The CSS geometry proof covers only centre focal, cover and no zoom
The
aspect-ratiobox proves the simple cover case. It does not yet define the transform for a non-centred focal point or zoom. The document says that only the intrinsic aspect ratio is emitted, while focal and zoom necessarily also affect the image-space-to-slot transform.Before freezing this part, specify the actual image/overlay DOM nesting and the complete transform for:
The CSS result should be checked against
ImageViewportGeometry, not merely promised as a later acceptance test. If the CSS surface needs per-deck focal/zoom rules, that also feeds back into point 2.4. The grammar currently contains contradictory rules
[A-Z]{1,2}(up toZZ), while the stated limit is 26 references (A–Z). Choose[A-Z]or a larger matching limit.x/yis its top-left or centre, require positive width/height, and requirex + w <= 1andy + h <= 1; validating each number separately is not sufficient.(A)is also ordinary prose. Define the exact behaviour for a natural sentence ending in(A), duplicate(A)references, bullet copy/paste and a duplicated bullet. At minimum, render only when exactly one front-matter entry and exactly one eligible bullet match; otherwise preserve everything and raise a checker finding.These are format semantics, not implementation details.
5. Target geometry and slide presentation need a compatibility matrix
The sample deliberately mixes point and region targets while the slide has one mode:
pin,regionorarrow. The design does not yet say:Define this once in the model contract so Flutter, HTML and LaTeX cannot make three different reasonable choices.
6. Accessibility storage exists, but renderer semantics do not yet
The prose says every target needs its own description, but the grammar/model stores one description per reference even when that reference has up to eight targets. Decide whether the description is explicitly for the target group or move it onto each target.
Also specify how the stored description becomes a programmatic relationship:
Storing prose and adding a halo are necessary, but not sufficient to establish those relationships.
7. Ownership of the v2 block needs a lossless merge contract
The preservation probe proves that v0.4.10 keeps the entire
ocideck_calloutsblock because that reader does not own the key. Once v2 owns the key, normal regeneration could still drop an unknown child, an unknown future target form, a comment or malformed source.The v2 codec therefore needs to retain the raw block alongside its typed view and merge only the entries it actually edits. Add tests for unknown nested entries, duplicate keys, comments, quoting, malformed known entries and a future geometry token through a new-reader edit/save, not only through the older reader.
The same data reaches YAML, generated HTML/attributes and LaTeX. Include hostile-but-valid descriptions containing quotes,
#,:,<,&,</script>and TeX metacharacters so each renderer proves it escapes at its own boundary.Status recommendation
The core direction — visible reference plus front-matter carrier, typed targets, shared geometry, generic reveal plan and fixed-rail arrows — remains sound. I would keep implementation slice 2 and later blocked until the seven points above are resolved in the design. #1803 and #1804 are correctly separate and need not wait for this discussion.
Design revision 4 — the seven points, answered
All seven are addressed in
docs/design/IMAGE_CALLOUTS.md(PR #1806). Two of them changed a decision rather than a wording, and one measurement turned up a pre-existing defect. The scope corrections are accepted: #1804 owns the plain-Marp theme problem, and rich text is now an explicit boundary rather than a feature gap.1. The release proof named the wrong version — accepted, and it was worse than stated
Verified against the forge's release list rather than against memory:
v0.4.10was published on 25 August 2026 (tagd84463bb, mergefaee0d64, non-draft, 12 assets) — andv0.4.9has no release at all. It is a tag whose release never happened, so it was never the reader in the field either. Revision 3 did not merely name an older release; it named one that was never released.The carrier conclusion survives: the diff over
markdown_parse/,markdown_service_parse.dart,markdown_service_serialize.dartandfront_matter_merge.dartbetweenv0.4.10andmainis empty. What the error genuinely invalidated is the claim that the newest released reader had been tested by name.The fix goes further than swapping a number, because a number in prose is exactly what went stale. §9 now names no version: the gate creates a worktree at whatever the newest released tag is, runs parse → generate on a checked-in fixture with that release's code, and diffs against a checked-in expectation. The fixture carries every element of the grammar — multiple targets, a region, a quoted and hostile description, an unknown nested entry, a
#comment inside the block, a malformed known entry and a future geometry token — and is then edited and saved by the new reader to prove point 7.2. Two representations of one fact — you are right, and the second one is gone
§2.3 now reads: nothing derived from the canonical block is persisted. No overlay markup in the slide body, no per-callout position rules in the generated theme. Every positioned overlay is produced at render or export time and thrown away with the frame.
Your hand-edit sequence is the argument, and the difference from the existing split scaffolding is exact: the scaffolding is structure, and there is no second place in the file that says what the structure should be. A position rule is a second copy of a semantic fact the front matter already states — and only one of the two copies is maintainable by hand.
The guardian decision is written into §2.3 rather than implied. The value at stake is the one the whole design rests on: the
.mdis the leading, hand-editable source. A foreign tool that draws nothing is honest; a foreign tool that draws the wrong thing is not — the author cannot see the difference, and gets blamed for it. So interchangeability wins again, and the price is named:marp --theme-setnow shows the layout, the text and(A), and no marks.The line that survives is not "generated versus hand-written". The generated theme may still carry the appearance of a mark — size, two-tone edge, accent — because that is deck styling that cannot go stale relative to a coordinate. The test is "does this restate a fact the canonical block already states".
What would reverse it: a carrier whose derived copy either cannot go stale, or can be detected as stale by the consuming tool. CSS can do neither.
Applying the rule to itself cost one more thing that was not in your list: §7 had the serialiser writing Marp
*fragment markers into the deck alongsidereveal:. That is the same second copy with smaller consequences — a hand-edit toreveal: allwould leave a foreign render still stepping — and a rule with one convenient exception is not a rule. The markers are gone; the front matter carries the intent, and a foreign render shows every bullet at once. Noted as worth revisiting: if OciDeck ever round-trips*losslessly, the marker becomes plain Markdown expressing the whole fact and the front-matter key can go — strictly better, but it is a change to how lists are written, not to this feature.3. The CSS geometry proof was one case out of many — now measured across the space
§4.1 gives the whole transform: the cover branch, the zoom branch, the clamp, and the two asymmetries that are easy to get wrong (in cover the focal point moves the picture, in zoom it moves the box and the picture is centred inside it; cover cannot push the image past its own edge, zoom can).
§4.2 measures the CSS against it in headless Chrome: 486 cases — 800×400, 400×800 and 500×500 images × slots at 20%, 40% and 70% of a 16:9 slide × zoom inputs 0, 100, 140, 400, −50 and 5000 ×
fx, fy ∈ {0, 0.5, 1}× 8 targets covering every corner and every edge midpoint, plus a region with width and height. Worst deviation: 0.03 px.Two formulations that looked correct were wrong, and both are now contract rather than folklore:
max-width: 100%; max-height: 100%; aspect-ratiois wrong by up to 2144 px. The direction is chosen when the CSS is generated, from the image ratio against the slot ratio — and that slot ratio is not measured: it is--image-width, already written into every split slide today, on a slide whose aspect the deck declares;position: relativeon the image box, marker percentages resolve against the zoom box instead of the picture — 1184 px out, with the picture itself in exactly the right place. Silent, and only in the zoom cases.The clamp belongs in the generator, because parsing does not bound
ocideck_image_zoomat all.3b. The measurement found something else: zoom above 100% does nothing
Measured with
flutter testagainst the exact widget composition of_zoomedImage, slot 512×720:imageZoomAlignlays its child out withconstraints.loosen(), which keeps the slot as the maximum, andSizedBoxenforces its size within the constraints it is given, so the oversized box is clamped straight back to the slot. The entire 100–400 range the slider offers renders identically. TheClipRectaround it guards an overflow that never happens.It has stayed invisible because the crop dialog's stage is built the same way and clamps the same way — measured too — so the editor and the slide agree with each other while both disagree with the slider. Nothing in the suite renders a zoomed panel.
It is pre-existing, it is not part of this feature, and it now has its own issue — #1813 — alongside #1803 and #1804. It is a prerequisite here for one reason: §4.1's zoom branch says what the file means, and the first surface to implement it faithfully would place callouts where Flutter does not.
4. Contradictory grammar rules — all four resolved
point 1.4 0.2is not a request to point at the edge; moving it there invents an answer and hides the defect. Not rendering is recoverable, a plausible wrong mark is not.[A-Z], matching the ceiling of 26.x yis the top-left corner,wandhare strictly positive, andx + w ≤ 1,y + h ≤ 1.§2.6 is a new table for
(A). Exactly one entry and exactly one eligible bullet renders; two bullets ending in(X)after a paste, a duplicate entry key, or an entry without a bullet all preserve everything and raise a named finding. A trailing(A)with no matching entry raises nothing at all — a deck without callouts must never be nagged about a sentence. No escape character is introduced: the editor simply never allocates a letter that already occurs as trailing prose on that slide, which is the only way the collision can arise.5. Mode against target — the whole matrix, in the model contract
§3.1 now defines all six cells. The load-bearing rule underneath them: a renderer may reduce geometry, never invent it. A region has a centre, so
pinover a region is a real answer. A point has no area, soregionover a point cannot conjure a default-sized box — that box would be geometry the author never wrote, and it would be a different box in each of the three renderers. Mixed point/region slides are valid in every mode, and each target is drawn by its own cell.6. Accessibility — storage was the easy half
§12 is new. The description belongs to the reference, not to each target:
C: point 0.610 0.480; point 0.700 0.300 | the two mounting boltsis one idea, and forcing a sentence per bolt produces worse prose, not more access. Targets are distinguished by ordinal. An author who means two things uses two references.The renderer semantics are specified rather than assumed: the description emitted once in a hidden element with a stable id; each marker
role="img"with an accessible name of reference, description plus target n of m; the bullet's<li>carryingaria-describedbyto that description, so the(A)a sighted reader uses as a join key has an equivalent; unrevealed groupshiddenand therefore absent from the accessibility tree, not merely invisible; one polite live region per slide announcing each reveal step and how many marks came with it. Flutter mirrors the same shape inSemantics. LaTeX has no tree, so the description has to survive as text. Raster PDF/PPTX/ODP remain not structurally accessible — that statement stands unchanged — with the descriptions appended to the picture's alt text wherever the target format has such a slot.7. Owning the key removes the protection that made it safe
§2.5, and this is the sharpest of the seven. The preservation evidence works precisely because the released reader does not own
ocideck_callouts:mergeFrontMattercopies every line of a key it does not own, indented block, comments and quoting included. The moment v2 adds the key tokOwnedFrontMatterKeys, an owned key's line and every continuation under it are dropped and replaced by what the generator emits — so the new reader would be the one destroying what the old one kept.The contract is therefore the same discipline
mergeFrontMatterapplies one level up, applied inside the block: raw lines kept alongside the typed view, only edited entries rewritten in place, everything else surviving exactly (unknown children, unparsed geometry,#comments, blank lines, order, quoting, a future token), duplicate keys preserved rather than collapsed, and the whole operation idempotent — the seal depends on that.The escaping matrix is an acceptance gate in its own right: one description carrying
",',#,:,<,&,</script>,\,{,},%,$,_,^,~and a newline, driven through all four writers —markdownYamlScalar,_escapeHtml, HTML attribute escaping foraria-label, and_escapeLatex. Each proves it escapes at its own boundary; none may rely on an earlier one having done it.Status
Revision 4 is merged (PR #1806), with
make check-static,make check-secrets,make sastand the docs gates green locally, andstatic-gateandscansgreen on the PR. §13 records what each revision changed and why, so the numbering in this thread stays followable.I would treat the format contract as frozen again once you are satisfied on these seven. Slice 2 stays blocked until then.
Of the three prerequisites, #1803 has landed today (the
imageZoomrepair plus the parity gate that watches the missing direction). #1804 continues independently, and #1813 joins them as the third.Thinking about this, you mention "callouts". These are used in source code to point to a point of interest. Similar, but different. Ysually something like a number with a circle around it. Your choice how to handle this.
Hercontrole na revisie 4 en de reactie van kwoot
Ik heb de reactie vergeleken met de daadwerkelijk gemergede tekst in
docs/design/IMAGE_CALLOUTS.md(PR #1806), en de genoemde code, historie en vervolgissues nagelopen.Uitkomst: de zeven eerdere punten zijn afgedekt
Revisie 4 verwerkt de eerdere review inhoudelijk, niet alleen redactioneel:
v0.4.10; de uiteindelijke poort kiest voortaan de nieuwste uitgebrachte tag in plaats van een versienummer uit proza.(A)-proza, duplicaten en wezen.#1803 is inmiddels gemergd. Conform de afgesproken afbakening heropen ik rich text hier niet als blokkade en blijft het Marp-onderwerp van #1804 buiten deze ontwerpbeoordeling.
Eén belangrijke verificatienuance voor de bouw
De Chrome-meting van 486 gevallen en maximaal 0,03 px afwijking staat nu als ontwerpevidence beschreven, maar de meetopstelling en vectoren zijn nog niet als uitvoerbare regressietoets in de repository terug te vinden. Dat is begrijpelijk zolang de functie en
ImageViewportGeometrynog niet bestaan, en het blokkeert het bevriezen van het ontwerp niet. Het betekent wel dat deze meting nog niet als blijvend repositorybewijs mag worden beschouwd.Bij werkstap 4 moeten daarom de vectorentabel en browser-/Fluttermetingen daadwerkelijk worden ingecheckt en via de normale kwaliteitspoort herhaalbaar worden. De acceptatiepoort in §9 vraagt dit al; bij implementatie moet die eis dus letterlijk worden uitgevoerd en niet worden afgedaan met alleen de getallen uit het ontwerpdocument.
Terminologie: reactie van kwoot
Kwoots waarschuwing is nuttig:
calloutkan voor ontwikkelaars associaties oproepen met broncodeannotaties, terwijl een gebruiker vooral wil aangeven welk deel van een afbeelding bij een tekstpunt hoort. Tegelijk is callout in technische illustraties ook een gangbare verzamelterm; de door kwoot genoemde omcirkelde nummers zijn precies depin-variant uit dit ontwerp. De termen botsen dus minder inhoudelijk dan in doelgroep en taalgebruik.Mijn voorstel is daarom:
ImageCalloutblijven staan als overkoepelend model;pin,region,arrowofcallout.Zo hoeft het model niet om cosmetische redenen te worden hernoemd, terwijl de gebruiker geen ontwikkelaarsterm hoeft te begrijpen. Ik hoor graag of dit aansluit bij wat kwoot bedoelt.
Klein redactioneel punt
In §9 staat in de opsomming van de kruisversiefixture de passage “an unknown nested entry, a
#comment inside the block, a malformed known entry” dubbel. Dat verandert het contract niet, maar kan bij de eerstvolgende documentwijziging worden opgeschoond.Eindoordeel
Voor mij zijn de zeven inhoudelijke reviewpunten hiermee gesloten en kan revisie 4 als bevroren ontwerp blijven gelden. De resterende punten hierboven zijn respectievelijk een uitvoeringspoort, een gebruikersgerichte naamkeuze en een redactionele correctie — geen reden om het ontwerp opnieuw open te breken.
Implementation plan — tracked as six mergeable slices
Revision 4 remains the frozen format contract. The implementation is now split into vertical issues: every user-visible presentation mode includes its authoring, all supported output surfaces, accessibility, localisation, tests and documentation in the same slice. Tests or export support are therefore not deferred into cleanup tickets.
Prerequisites
Implementation slices
Completion gate for this parent issue
mainThe intended order is #1824 → #1825 → #1826 → #1827 → #1828 → #1829. Each pull request should still use small coherent commits rather than one feature-sized commit.
#1804 remains intentionally separate: it concerns ordinary Marp theme loading, not the image-reference contract. None of the six new issues is marked
in-progress; they are accepted work, but are only claimed when an implementation branch actually starts.For terminology, the internal model may retain
ImageCallout, while user-facing text uses image references, numbered marker, highlighted area and arrow.Afsluiting — alle completion gates behaald
De vier criteria uit het implementatieplan (#1801 (comment)) zijn allemaal groen:
1. Alle child-issues gemerged en geverifieerd op
main2. Volledige user journey getest (functioneel, visueel, nieuwkomer)
3. Cross-surface checks groen (geometrie, toegankelijkheid, privacy, collaboratie)
4. Documentatie & compatibiliteitsmatrix klopt met gedrag
Follow-ups uit beeldkeuring en nieuwkomer-test
Alle bevindingen die tijdens de handmatige gates naar boven kwamen, zijn opgepakt en gesloten: #1853, #1854, #1855, #1856, #1857, #1858, #1859, #1860, #1861, #1862, #1863, #1864, #1865.
Het ontwerp (rev 4,
docs/design/IMAGE_CALLOUTS.md) is bevroren en geïmplementeerd. De feature is volledig geland opmain.