Skip to content

feat(vscode): export diagrams as SVG/PNG from the Docify preview panel#2675

Open
YoofiTT96 wants to merge 8 commits into
finos:mainfrom
YoofiTT96:feat/vscode-diagram-export
Open

feat(vscode): export diagrams as SVG/PNG from the Docify preview panel#2675
YoofiTT96 wants to merge 8 commits into
finos:mainfrom
YoofiTT96:feat/vscode-diagram-export

Conversation

@YoofiTT96

@YoofiTT96 YoofiTT96 commented Jun 17, 2026

Copy link
Copy Markdown
Member

Description

Adds SVG and PNG diagram export to the VSCode extension's Docify preview panel. An Export dropdown (built with <vscode-button> + <vscode-context-menu>) appears in the diagram toolbar alongside the existing zoom controls. Selecting a format triggers VSCode's native save dialog and writes the file to disk.

Closes #2674.

The VSCode companion to the CLI --export-diagrams flag (#2634): the CLI handles batch export during documentation generation; this handles interactive single-diagram export from within the IDE.

Key implementation details

Webview (diagram-export.ts) — three fixes are applied to the live SVG before serialising, to ensure the exported file renders correctly as a standalone image:

  • Restores Mermaid's original viewBox (stashed as data-original-viewbox before svg-pan-zoom strips it), and sets explicit width/height, so the file has correct intrinsic dimensions.
  • Inlines the computed font-family as a presentation attribute on the root <svg>, so unstyled edge-label text does not fall back to the viewer's default serif font.
  • Sets overflow: visible on all <foreignObject> elements, so labels are never clipped if a different renderer measures glyphs slightly wider.

For PNG, the same fixed clone is mounted in an off-screen <div> (not on the clone itself, to avoid html-to-image inlining position: fixed into the rasterised image) and rasterised at 2× pixel ratio.

Extension host (preview-panel.ts)handleExportDiagram() opens a save dialog pre-filled with <arch-basename>-diagram-<n>.<ext> in the same directory as the open CALM file, then writes the SVG string or base64-decoded PNG buffer via vscode.workspace.fs.

Type of Change

  • 🐛 Bug fix (non-breaking change which fixes an issue)
  • ✨ New feature (non-breaking change which adds functionality)
  • 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • 📚 Documentation update
  • 🎨 Code style/formatting changes
  • ♻️ Refactoring (no functional changes)
  • ⚡ Performance improvements
  • ✅ Test additions or updates
  • 🔧 Chore (maintenance, dependencies, CI, etc.)

Affected Components

  • CLI (cli/)
  • Schema (calm/)
  • CALM AI (calm-ai/)
  • CALM Hub (calm-hub/)
  • CALM Hub UI (calm-hub-ui/)
  • CALM Server (calm-server/)
  • CALM Widgets (calm-widgets/)
  • Documentation (docs/)
  • Shared (shared/)
  • VS Code Extension (calm-plugins/vscode/)
  • Dependencies
  • CI/CD

Commit Message Format ✅

Key commits follow conventional format:

  • feat(vscode): export Mermaid diagrams as SVG/PNG from Docify preview
  • feat(vscode): replace SVG/PNG export buttons with an Export dropdown
  • fix(shared): center exported diagrams and match export font to IDE rendering

Testing

  • I have tested my changes locally
  • I have added/updated unit tests
  • All existing tests pass

New unit tests added:

  • diagram-export.spec.ts — covers serializeSvgElement, rasterizeSvgElementToPng, exportDiagram, and all three SVG fix helpers with jsdom mocks.
  • diagram-controls.spec.ts — verifies the Export dropdown renders correctly, is absent when no callbacks are provided, and dispatches the correct callback on selection.
  • preview-panel.spec.ts — verifies handleExportDiagram opens the save dialog, writes the correct buffer, and shows the confirmation message.

Checklist

  • My commits follow the conventional commit format
  • I have updated documentation if necessary
  • I have added tests for my changes (if applicable)
  • My changes follow the project's coding standards

@linux-foundation-easycla

linux-foundation-easycla Bot commented Jun 17, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

@YoofiTT96 YoofiTT96 force-pushed the feat/vscode-diagram-export branch from ea4dec0 to 15e655c Compare June 17, 2026 21:51
@github-actions github-actions Bot added cli Affects `cli` code calm-hub Affects `calm-hub` calm-hub-ui Affects `calm-hub-ui` docs Improvements of additions to documentation labels Jun 17, 2026
Adds SVG/PNG export to the diagram toolbar in the Docify preview tab. Export restores Mermaid's original viewBox (stripped by svg-pan-zoom), inlines the resolved font, and prevents foreignObject labels from being clipped, so both formats render the full diagram exactly as laid out.
Consolidates the two inline SVG/PNG export buttons in the diagram toolbar into a single "Export" button with a dropdown menu, using @vscode-elements/elements (vscode-button + vscode-context-menu) for a native VS Code look.
@YoofiTT96 YoofiTT96 force-pushed the feat/vscode-diagram-export branch from 15e655c to 7f64ee4 Compare June 17, 2026 21:57
@YoofiTT96 YoofiTT96 added vscode Affects vscode extension dependencies Pull requests that update a dependency file and removed calm-hub Affects `calm-hub` calm-hub-ui Affects `calm-hub-ui` cli Affects `cli` code config labels Jun 17, 2026

@LeighFinegold LeighFinegold left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I applied a comment in wrong place

@LeighFinegold LeighFinegold left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great overall. Solid feature, good test coverage.

I'd be curious to hear what model/tooling you used and how you prompted it - and in particular what context you fed it about the existing architecture. Reason being: there are a few design choices that go against patterns already established in this extension, and I want to understand whether that's intentional divergence or something that slipped through.

The issues are all fixable and I'd be happy to pair on them. I've left inline comments on the specific files below.

this.viewModel.handleToggleLabels(showLabels)
}

public async handleExportDiagram(format: 'svg' | 'png', data: string, diagramIndex: number): Promise<void> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method combines three responsibilities in one place:

  1. Path/filename computation (business logic - dirname, basename, fallback to workspace root)
  2. Save dialog interaction (VSCode UI - showSaveDialog)
  3. File write + error handling (I/O - workspace.fs.writeFile)

The extension follows MVVM where ViewModels are framework-free (no vscode imports) and the panel is the View layer that only handles VSCode-specific I/O (see calm-plugins/vscode/AGENTS.md - Key Design Principles):

  1. Framework Isolation: ViewModels have NO vscode imports
  2. Dependency Inversion: Core depends on ports, not VSCode

The path computation logic should live in a ViewModel or a small ExportService - the panel just calls showSaveDialog and writeFile with whatever the ViewModel gives it.

Compare with how handleRunDocify (same file) delegates to DocifyService and emits results via the ViewModel event system. This new handler should follow the same shape.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. New change pulls the filename/path logic and the SVG-vs-PNG data decoding into a new DiagramExportService— a plain class with no vscode import, same shape as the existing ModelService. The panel now just calls diagramExportService.computeDefaultPath(...) to get a path, hands that to showSaveDialog, then calls diagramExportService.decodeExportData(...) before workspace.fs.writeFile

* svg-pan-zoom strips it). Build the same fixed clone used for SVG export and render
* it off-screen so html-to-image can compute its layout and styles before rasterizing.
*/
export async function rasterizeSvgElementToPng(svg: SVGSVGElement): Promise<string> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two concerns with this dependency:

1. CSP silent degradation: The webview's Content Security Policy (media/preview.html) sets default-src 'none' with no connect-src override, which means all fetch/XHR requests from within the webview are blocked. html-to-image uses fetch() internally to download font files so it can inline them into the exported image. Those fetches fail silently, so the library falls back to whatever system font is available.

Having watched the demo video, I think the fonts in the exported PNG are actually slightly different from what's rendered in the webview - but it's not noticeable unless you're looking for it. It'll get worse with custom fonts/themes though, and there's no warning when it happens.

Let me know if this is not the case.

2. Unnecessary dependency: You already have the fixed SVG clone from buildExportClone. Can we rasterize using native browser APIs available in the webview?

@YoofiTT96 YoofiTT96 Jun 19, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes there was a very slight difference in node labels that I put down to rasterisation process + different formats. However, my attempts with using native browser apis + pipeline failed consistently with:
SecurityError: Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported.

This is because drawing an SVG that contains a onto a canvas via the browser's native <img> decoder taints the canvas in Chromium even when the foreignObject content is 100% local/self-contained. Mermaid renders node/edge labels via <foreignObject> by default, so this fails on every diagram, not just ones with custom content.

html-to-image solves the problem by walking the DOM directly and inlining everything into their own representation before rasterizing, rather than handing the browser's native SVG decoder something it'll refuse to export. So the dependency is specific to mermaids rendering approach.

@YoofiTT96 YoofiTT96 Jun 19, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performed some more tests and the taint issue can be circumvented by switching from setting the img source from a blob url to data uri built fromt he SVG string. Data uris are known to increase bloat but it is transient and only lives for the duration of the call. For a hypothetical very large architecture multi MB diagram it would add a one time decode cost. Flagging this possibility but would be fine for majority of use cases.

The rasterization pipeline now does:

  1. Serialize the already-fixed SVG clone to a string (no network access).
  2. Encode it as a data: URI via Blob + FileReader (no network access — this is local encoding, not a fetch).
  3. Load that into an Image, draw to , read back via toDataURL() (no network access).

Also side steps the issue with possible future custom fonts and removes any issues with the CSP as no fetches are performed

* Create the "Export" button with an SVG/PNG dropdown menu.
* Returns null if neither export callback was provided.
*/
private createExportControl(): HTMLElement | null {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DiagramControls was a focused single-responsibility class: zoom/pan UI controls (original file before this PR - 110 lines, just buttons + PanZoomManager calls).

This PR adds:

  • Web component imports (@vscode-elements/elements/dist/vscode-button/index.js, @vscode-elements/elements/dist/vscode-context-menu/index.js)
  • A dropdown menu with show/hide toggle state
  • Export callback wiring

Two issues:

  1. SRP: Extract this to a separate DiagramExportControl class composed alongside DiagramControls in docify-tab.view.ts, not nested inside it.
  2. Consistency: The existing zoom buttons are plain <button class="diagram-control-btn"> elements. The export control uses <vscode-button> + <vscode-context-menu> web components - creating visual inconsistency. Also, the imports use private distribution paths (/dist/vscode-button/index.js) that aren't part of the package's public API and can break on minor bumps.

@YoofiTT96 YoofiTT96 Jun 19, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point on mixing of concerns that should duly be extracted.
For the latter I used vscode elements intiially for the look and feel but was prepared to revert based on feedback as its cosmetic. It also provided some accessibility controls with using the keyboard to navigate the menu options. For now those controls can be implemented without the dependency. If more controls and menu options come up in the future the dependency can be reintroduced

Reverted to regular buttons

Screenshot 2026-06-19 at 11 55 03 AM

Comment on lines +94 to +96
export class ExportDiagramCmd implements WebviewCommand<{ type: 'exportDiagram'; format: 'svg' | 'png'; data: string; diagramIndex: number }> {
readonly type = 'exportDiagram' as const
constructor(private p: PreviewCommandTarget) { }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the first command in the registry that returns Promise<void> from execute(). But CommandRegistry.dispatch (line 43, same file) doesn't await it:

dispatch(msg: InMsg) { this.map.get(msg.type)?.execute(msg as any) }

If the async chain rejects outside the try/catch in handleExportDiagram (e.g. panel disposed mid-write, a race with dispose()), the promise rejection is unhandled.

Minor fix - make dispatch async-aware:

async dispatch(msg: InMsg) { await this.map.get(msg.type)?.execute(msg as any) }

Or add .catch() in the command itself. Not a blocker but worth hardening now that async commands are a thing.

@YoofiTT96

YoofiTT96 commented Jun 19, 2026

Copy link
Copy Markdown
Member Author

This looks great overall. Solid feature, good test coverage.

I'd be curious to hear what model/tooling you used and how you prompted it - and in particular what context you fed it about the existing architecture. Reason being: there are a few design choices that go against patterns already established in this extension, and I want to understand whether that's intentional divergence or something that slipped through.

The issues are all fixable and I'd be happy to pair on them. I've left inline comments on the specific files below.

For tooling Sonnet 4.6 with High Effort. Likely slipped through on these design choices that are present in the AGENTs.md. Main context deliberately called out was the merge of the previous docify feature addition. Expected it to detect the CLAUDE.md which points to the AGENTs.md but seems that did not happen. The session pattern-matched against the nearest existing code — handleExportDiagram got bolted onto the same function shape as whatever wasalready in preview-panel.ts, and the export dropdown got added directly inside DiagramControls because that's where the zoom buttons already lived . Will go through again along with proposed fixes

Using the @vscode-elements/elements and html-to-image were deliberate. The former for the look and feel of the buttons to look more like native vs code options. It was previously rendering them as svg buttons much like the zoom controls. However this was more a cosmetic decision as the plain button elements were also suitable. The latter for PNG rasterisation but without checking the web views CSP.

Extract DiagramExportService (framework-free) so preview-panel.ts only
handles VSCode I/O (save dialog, file write) for diagram export, moving
path computation and data decoding out of the View layer.

Replace html-to-image with native Image+canvas rasterization for PNG
export, loading the SVG via a data: URI rather than a blob: URL to
avoid Chromium tainting the canvas when rasterizing SVGs containing
foreignObject (which Mermaid uses for labels). Drops the html-to-image
dependency and needs no CSP changes since no network fetch is involved.

Extract DiagramExportControl from DiagramControls (was bolted onto the
zoom/pan control class) and drop @vscode-elements/elements so the
export button matches the existing plain-button toolbar styling and
avoids importing the library's private dist paths. Adds Escape/Arrow-key/
focus-out keyboard handling to the export menu to replace the keyboard
navigation the dropped dependency provided.

Make CommandRegistry.dispatch catch rejections from async commands
(e.g. ExportDiagramCmd) so a failure doesn't surface as an unhandled
promise rejection.
…96/architecture-as-code into feat/vscode-diagram-export
@YoofiTT96

YoofiTT96 commented Jun 19, 2026

Copy link
Copy Markdown
Member Author

Summary of what changed, plus a couple of things the fixes themselves surfaced along the way.

1. handleExportDiagram mixing path computation, save-dialog UI, and file I/O — extracted a DiagramExportService (core/services/diagram-export-service.ts), framework-free like ModelService. It owns the default-path computation and SVG/PNG data decoding. preview-panel.ts now only calls showSaveDialog/workspace.fs.writeFile with whatever the service hands it.

2. html-to-image + CSP-blocked font fetches — removed the dependency entirely. PNG export now rasterizes the already-fixed SVG clone natively via Image + <canvas>, with no fetches involved at all. One thing this surfaced: my first attempt loaded the SVG via a blob: URL, which hits a real Chromium restriction — canvases are tainted when rasterizing an SVG containing <foreignObject> (which Mermaid uses for labels) via blob:, but never via data: URIs, in any browser, independent of content. Switched to encoding via Blob + FileReader.readAsDataURL() (base64, Unicode-safe) instead of URL.createObjectURL(), which resolved it cleanly.

3. DiagramControls SRP violation, visual inconsistency, and private import paths — extracted DiagramExportControl as its own class, composed alongside DiagramControls in `docify-tab.v

iew.ts. Dropped @vscode-elements/elementsentirely; the Export button and menu are now plain elements styled to match the existing.diagram-control-btntoolbar, so all four toolbar controls look consistent. This did mean losing the keyboard navigationvscode-context-menuprovided for free, so I added it back by hand:Escape` closes the menu and refocuses the trigger, arrow keys move between and wrap around menu items (and open the menu directly when pressed on the trigger), and focus leaving the control closes it. Flagging the hand implemented accessibility controls in case we want to change the button rendering to use a dependency that handles it

4. CommandRegistry.dispatch not awaiting async commandsdispatch now attaches a .catch() to whatever execute() returns, so a rejection (e.g. a disposed panel racing a write) cannot become an unhandled promise rejection.

All four areas have new or updated unit tests; full suite passing (489 tests), lint clean, build verified, and manually exercised in the Extension Development Host (export SVG/PNG, toolbar styling, keyboard nav on the Export menu).

Screen.Recording.2026-06-19.at.1.21.06.PM.mov

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

config dependencies Pull requests that update a dependency file docs Improvements of additions to documentation shared vscode Affects vscode extension

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(vscode): export diagrams as SVG/PNG from the Docify preview panel

2 participants