Skip to content

Latest commit

 

History

History
499 lines (394 loc) · 20.6 KB

File metadata and controls

499 lines (394 loc) · 20.6 KB

PLAN-13: Phase 13 --- Inline SVG embedding with zoom / export controls

Replaces <img src="...svg"> rendering with build-time SVG inlining, and unifies the gantt-chart injection with the same pipeline. Every .svg image referenced from markdown (including .dot-generated diagrams) is read from disk at render time, embedded directly in the page HTML, and wrapped in a controls bar (Download SVG, Copy SVG, Download PNG, Copy PNG) with a click-to-zoom fullscreen overlay.

The gantt chart on the BuildInfo page flows through the same markdown-it plugin as every other SVG. Because the gantt's timing data is not available until after the build completes, the plugin inlines a committed placeholder SVG; a minimal post-build step substitutes the real content into the already-written HTML. The wrapper markup, controls, zoom wiring, and JS are identical --- the only difference is when the SVG content arrives.

What Phase 13 does NOT do:

  • Add dark-mode colour support for Graphviz .dot diagrams. The gantt SVG already carries html.dark-mode CSS rules that work because it is inline; the .dot SVGs generated by @hpcc-js/wasm-graphviz do not. Adding dark-mode classes to .dot source files is a separate concern.
  • Change the existing .dot.svg regeneration pipeline (dot.mjs). Diagrams are still rendered from .dot source by WASM Graphviz; this plan only changes how the resulting .svg files are embedded in pages. (One .dot source fix WAS needed: pdf-render-pipeline.dot had margin=12 in its graph [...] defaults block, which Graphviz interprets as 12 inches on the root graph --- producing a 2239×2406 pt SVG with 864 pt of dead space. Moved the margin=12 to each subgraph cluster_* where it means 12 points of intra-cluster padding.)
  • Touch the offline or PDF build passes. Inline SVGs are already part of the page HTML by the time those passes run; no rewriting is needed.
  • Add new npm dependencies.

Target wall-clock impact: negligible. The three .dot SVGs total ~30 KB; reading them adds < 1 ms to the dispatch task. The per-page render cost is unchanged (string concatenation vs. <img> tag emission).


1. Current state

SVG images in markdown (![alt](/assets/images/dot/foo.svg)) render as <img src="/tB/assets/images/dot/foo.svg">. The browser makes a separate HTTP request per SVG. Page CSS cannot reach the SVG internals (dark-mode styling, link colours, etc.). No zoom or download controls exist.

The gantt chart on the BuildInfo page has a separate code path: injectGanttChart() in tbdocs.mjs (lines 756--816) runs after the build completes and patches the written HTML, injecting the inline SVG, four control links, a PNG-export function, and a click-to-zoom script. This is ~60 lines of self-contained HTML / JS generation that duplicates the pattern every future SVG would need.


2. Architecture

2.1. Data flow: SVG contents through the pipeline

  dot.mjs (seed)                       dispatch (main thread)
  ┌─────────────────┐                  ┌──────────────────────────┐
  │ .dot → .svg     │──staticFiles──▸  │ reads .svg file contents │
  │ regeneration    │                  │ into svgContentsMap      │
  └─────────────────┘                  │ packs into sharedSAB    │
                                       └────────────┬─────────────┘
  docs/assets/images/gantt.svg                      │
  (committed placeholder)                           │
  ───── also in staticFiles ────────────────────────┘
                                                    │
                              ┌──────────────────────┘
                              ▼
              ┌──────────────────────────────────┐
              │  cpu-worker renderEnvInit         │
              │  unpackShared → svgContentsMap    │
              │  createMarkdownIt({ ...,          │
              │    svgContents })                 │
              └──────────────┬───────────────────┘
                             ▼
              ┌──────────────────────────────────┐
              │  svgInlinePlugin (image renderer) │
              │  • src ends in .svg?              │
              │  • content in ctx.svgContents?    │
              │  → emit wrapper + inline SVG      │
              │  → set page.hasSvg = true         │
              └──────────────┬───────────────────┘
                             ▼
              ┌──────────────────────────────────┐
              │  templatePhase / renderHead       │
              │  page.hasSvg → include            │
              │  svg-inline.js <script> tag       │
              └──────────────────────────────────┘

2.2. Gantt placeholder lifecycle

  1. docs/assets/images/gantt.svg is a committed, minimal SVG placeholder (a few bytes; empty <svg> with a viewBox).
  2. discover finds it; dot.submit or the static-file list includes it. dispatch.execute reads its content along with every other .svg.
  3. The plugin inlines the placeholder into BuildInfo's renderedContent --- identical wrapper markup to every other SVG. The container carries data-svg-src="assets/images/gantt.svg".
  4. templatePhase wraps it in the full page HTML. writePhase writes it to _site/ (and _site-offline/).
  5. After the build completes, injectGanttChart() (now ~10 lines) reads the BuildInfo HTML back, finds the container by its data-svg-src, replaces the placeholder <svg>…</svg> with the real gantt SVG, and writes the file back. It also writes the real gantt SVG to _site/assets/images/gantt.svg so a direct-URL download works.

2.3. Wrapper HTML shape (emitted by the plugin)

The plugin emits this HTML for every inlined SVG:

<div class="svg-inline-wrap">
  <div class="svg-controls">
    <a href="#" data-action="download-svg"
       data-filename="<stem>">Download SVG</a>
    <a href="#" data-action="copy-svg">Copy SVG</a>
    <a href="#" data-action="download-png"
       data-filename="<stem>">Download PNG</a>
    <a href="#" data-action="copy-png"
       data-filename="<stem>">Copy PNG</a>
  </div>
  <div class="svg-container" data-svg-src="<srcRel>"
       role="img" aria-label="<alt>">
    <svg ></svg>
  </div>
</div>

<stem> is the filename without extension (e.g. scheduler-dag). <srcRel> is the source-relative path (e.g. assets/images/dot/scheduler-dag.svg). <alt> is the markdown alt text, used for accessibility (the <svg> element itself may or may not carry a <title>; the aria-label on the container is the reliable fallback).

2.4. JS behaviour (svg-inline.js)

A single event-delegation script loaded only on pages where page.hasSvg is true. Four behaviours, all driven by class / data-attribute selectors:

Action Trigger Implementation
Zoom Click on .svg-container (not on a control) Toggle the container to position:fixed fullscreen overlay; match body background colour; Escape to close
Download SVG Click [data-action="download-svg"] Serialize sibling <svg> via XMLSerializer, create Blob, trigger download
Copy SVG Click [data-action="copy-svg"] navigator.clipboard.writeText(svg.outerHTML)
Download / Copy PNG Click [data-action="download-png"] or [data-action="copy-png"] Serialize SVG → Blob → <img><canvas> at 2048 px wide → PNG blob → download or clipboard write

Print media: the script injects @media print { .svg-controls { display:none } } once on load.

Sizing: the script also injects .svg-container svg { width: 100%; height: auto } so every inlined SVG fills the column width and scales proportionally.

Total size: ~80 lines / ~2.5 KB uncompressed. No cost on pages without SVGs (the <script> tag is not emitted).


3. Module split

builder/
  render.mjs                +65 / -0 net. svgInlinePlugin function
                             (~40 lines) + buildSvgWrapper helper
                             (~25 lines). Registered last in
                             createMarkdownIt's plugin chain (after
                             relativeLinksPlugin, which rewrites the
                             src URL before the image renderer fires).

  template.mjs              +4 / -0. In renderHead, after the
                             just-the-docs.js <script> line,
                             conditionally emit a <script defer> for
                             svg-inline.js when page.hasSvg is true.

  tbdocs.mjs                +15 / -55 net. dispatch.execute reads
                             .svg staticFiles into svgContentsMap
                             and packs it into the shared SAB.
                             injectGanttChart collapses from ~60
                             lines to ~15 (find container by
                             data-svg-src, replace <svg> content,
                             write gantt.svg to _site/).

  cpu-worker.mjs            +3 / -1. renderEnvInit unpacks
                             svgContentsMap from sharedSAB and passes
                             it to createMarkdownIt.

  sab-broadcast.mjs          0 / 0. No changes; packShared /
                             unpackShared are generic JSON.

docs/
  assets/images/gantt.svg   NEW. Committed placeholder (~100 bytes).
                             Never updated by the build; the real
                             gantt is injected into _site/ only.

  assets/js/svg-inline.js   NEW. ~60 lines. Zoom, download, copy
                             behaviours via event delegation.

  Documentation/
    BuildInfo.md            +1 / -1. Replace <!-- gantt-chart -->
                             with ![Build task timeline](/assets/
                             images/gantt.svg).

Estimated total churn: ~90 lines added, ~55 removed, plus the two new files (~160 lines combined).


4. Implementation order

Three batches; each independently committable and verifiable. Batch 2 depends on batch 1; batch 3 depends on batch 2.

Batch Substeps Suggested model Verifies by
1 §5.1 + §5.2 + §5.3 --- the plugin, the data plumbing, and the conditional script tag. Regular .dot SVGs are now inlined. Opus build.bat && check.bat clean; inspect _site/Documentation/Development/Builder/index.html to confirm the three .dot SVGs appear as inline <svg> wrapped in .svg-inline-wrap, not as <img> tags; the <script src="svg-inline.js"> tag is present in that page's <head> and absent in a page with no SVGs (e.g. _site/tB/Core/Dim/index.html).
2 §5.4 --- the JS file with zoom / download / copy behaviours. Sonnet Manual: open the Builder page in a browser; click a diagram to enter fullscreen zoom; press Escape to close; click each of the four control links and verify the download / clipboard result.
3 §5.5 + §5.6 --- committed placeholder, simplified gantt injection, BuildInfo.md change. Sonnet build.bat && check.bat clean; the BuildInfo page shows the gantt chart with the same zoom + controls as the .dot diagrams; the old <!-- gantt-chart --> comment is gone; injectGanttChart is < 20 lines.

4.1. Model-selection rationale

  • Opus (batch 1): the core architectural work. The plugin must correctly override the image renderer without breaking non-SVG images, handle the URL-to-srcRel mapping across baseurl configurations, set the page flag at the right time, and the SAB plumbing must survive the structured-clone serialisation.
  • Sonnet (batches 2, 3): batch 2 is a self-contained JS file with no interaction with the build pipeline; batch 3 is a mechanical simplification of existing code plus a one-line markdown change.

5. Per-substep specifications

5.1. SVG inline plugin in render.mjs

Location: new function svgInlinePlugin(md, ctx), registered in createMarkdownIt as the last md.use(...) call (after flattenAdjacentStrongPlugin).

Mechanism: overrides md.renderer.rules.image.

function svgInlinePlugin(md, ctx) {
  const orig = md.renderer.rules.image;

  md.renderer.rules.image = (tokens, idx, options, env, self) => {
    const token = tokens[idx];
    const srcIdx = token.attrIndex("src");
    if (srcIdx < 0) return fallback();
    const src = token.attrs[srcIdx][1];
    if (!src.endsWith(".svg")) return fallback();

    // Map the rewritten URL back to srcRel.
    // relativeLinksPlugin has already resolved the URL to
    // e.g. "/tB/assets/images/dot/scheduler-dag.svg".
    const prefix = (ctx.baseurl || "") + "/";
    if (!src.startsWith(prefix)) return fallback();
    const srcRel = src.slice(prefix.length);

    const svgContent = ctx.svgContents?.get(srcRel);
    if (!svgContent) return fallback();

    // Extract alt text (same as default image renderer).
    const alt = self.renderInlineAsText(
      token.children, options, env);
    const stem = srcRel.split("/").pop().replace(/\.svg$/, "");

    if (env?.page) env.page.hasSvg = true;

    return buildSvgWrapper(svgContent, alt, stem, srcRel);

    function fallback() {
      if (orig) return orig(tokens, idx, options, env, self);
      // markdown-it default image rule
      token.attrs[token.attrIndex("alt")][1] =
        self.renderInlineAsText(token.children, options, env);
      return self.renderToken(tokens, idx, options);
    }
  };
}

buildSvgWrapper(svgContent, alt, stem, srcRel) returns the HTML string from §2.3. The controls use data-action / data-filename attributes; no inline event handlers.

The function escapes alt and srcRel for attribute context (double-quote safe).

5.2. SVG contents in the shared SAB

Read: in dispatch.execute(), after the const shared = { block. Read every .svg entry in state.staticFiles from disk:

const svgContentsMap = Object.create(null);
for (const f of state.staticFiles) {
  if (f.srcRel.endsWith(".svg") && f.srcPath) {
    try {
      svgContentsMap[f.srcRel] = await fs.readFile(f.srcPath, "utf8");
    } catch {}
  }
}

Add svgContentsMap to the shared object. packShared serialises it into the SAB alongside staticFilesArr, etc.

dispatch.execute is currently synchronous (no async). Adding the fs.readFile calls makes it async execute(...). The scheduler already supports async execute functions; no framework change needed.

Unpack: in cpu-worker.mjs renderEnvInit():

const { ..., svgContentsMap } = unpackShared(_sharedSAB);
// ...
const svgContents = new Map(Object.entries(svgContentsMap ?? {}));
const markdown = createMarkdownIt({
  highlighter, linkTables, baseurl, staticFiles, svgContents,
});

Main-thread markdown-it (markdownInit task): this instance is used only for SEO title rendering and does not render page bodies. No SVG contents needed; pass svgContents: new Map() (or omit).

5.3. Conditional script in template.mjs

In renderHead, after the just-the-docs.js <script> line (line 130), add:

(page.hasSvg
  ? `  <script src="${escAttr(relativeUrl(
      "/assets/js/svg-inline.js", bu))}" defer></script>\n`
  : "")

defer keeps the load non-blocking, same as theme-switch.js.

5.4. svg-inline.js

New file at docs/assets/js/svg-inline.js. ~80 lines, no dependencies, IIFE.

Behaviours:

  1. CSS injection. On load, inject a <style> with @media print { .svg-controls { display:none } }, the base .svg-controls a float styling, and .svg-container svg { width:100%; height:auto } so every inlined SVG fills the column width.

  2. Zoom toggle. document.addEventListener("click", ...), delegated to .svg-container. If the click target is inside .svg-controls, ignore (let the control handler run). Otherwise toggle the container between normal flow and position:fixed; inset:0; z-index:9999; background:<body bg>; overflow:auto. Set cursor:zoom-in / zoom-out. Track state via dataset.zoomed. Escape keydown closes the zoom (with {capture:true} so it fires before other handlers).

  3. Control actions. document.addEventListener("click", ...), delegated to .svg-controls a[data-action]. preventDefault. Navigate from the clicked link to the sibling .svg-container > svg.

    • download-svg: new XMLSerializer().serializeToString(svg) → Blob → object URL → programmatic <a> click.
    • copy-svg: navigator.clipboard.writeText(svg.outerHTML).
    • download-png / copy-png: serialize SVG → Blob → <img>.onload<canvas> at 2048 px wide (aspect from viewBox) → canvas.toBlob("image/png") → download or navigator.clipboard.write([new ClipboardItem(...)]).

    data-filename on the link provides the download stem (<stem>.svg, <stem>.png).

5.5. Gantt unification

Committed placeholder (docs/assets/images/gantt.svg):

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 1">
  <title>Build task timeline</title>
</svg>

Minimal; the real content replaces it in _site/ at the end of every build.

Simplified injectGanttChart(pages, destRoot, svgContent):

Collapses to ~15 lines. No baseurl parameter --- static files live directly under the output root (_site/assets/...), not under the baseurl prefix. For each output tree (destRoot, destRoot-offline):

  1. Read the BuildInfo HTML from disk.
  2. Find the data-svg-src="assets/images/gantt.svg" container. Extract the range from the first <svg after that marker to its closing </svg>.
  3. Replace that range with svgContent.
  4. Write the patched HTML back.
  5. Write svgContent to <root>/assets/images/gantt.svg so a direct-URL download of the file works.

No controls, no scripts, no zoom wiring --- the plugin already emitted all of that around the placeholder.

5.6. BuildInfo.md update

Replace:

<!-- gantt-chart -->

With:

![Build task timeline](/assets/images/gantt.svg)

The plugin inlines the placeholder SVG and emits the full wrapper. The post-build step fills in the real content.


6. Edge cases

6.1. First build on a clean checkout

docs/assets/images/gantt.svg is the committed placeholder. The plugin inlines it --- the BuildInfo page renders with an empty gantt area and working controls. After the build completes, the post-build step fills in the real gantt. On the NEXT page load (or if the user refreshes), the real gantt is visible.

6.2. Missing SVG file

If an ![...](....svg) references a file not in staticFiles (e.g. a typo), the plugin falls through to the default <img> renderer. The link checker (check.bat) catches the broken image reference as it does today.

6.3. Non-local SVG URLs

Absolute URLs (https://...svg), protocol-relative URLs (//...svg), and fragment-only references (#...) bypass the plugin entirely (the src.startsWith(prefix) guard rejects them). Only build-local SVGs with content in the svgContents map are inlined.

6.4. Serve mode

No special handling needed. The watcher already ignores assets/images/dot/*.svg writes. The gantt placeholder at docs/assets/images/gantt.svg is never written during the build (only _site/ is touched). Each serve rebuild reads the placeholder, inlines it, and the post-build step injects the real gantt into _site/.

6.5. SVG size in the shared SAB

The three .dot SVGs and the gantt placeholder total ~30 KB. The existing shared SAB carries ~600 KB of serialised data (linkTablesData + staticFilesArr + sitePathsArr). The SVG contents add ~5 % overhead --- negligible.

6.6. Accessibility

The <img alt="..."> tag is replaced by a <div ... role="img" aria-label="..."> on the container. Screen readers announce the alt text; the <svg> element's internal <title> (if present) provides a secondary label.