Skip to content

feat(vector): read Obsidian canvases (JSON Canvas) to SVG, PDF, PNG and Markdown - #1445

Open
Ugoyo wants to merge 4 commits into
KnifeLemon:mainfrom
Ugoyo:claude/json-canvas
Open

Ugoyo wants to merge 4 commits into
KnifeLemon:mainfrom
Ugoyo:claude/json-canvas

Conversation

@Ugoyo

@Ugoyo Ugoyo commented Nov 12, 2026

Copy link
Copy Markdown
Collaborator

What does this change?

Obsidian's visual boards (.canvas) follow the open JSON Canvas 1.0 spec (jsoncanvas.org, MIT). They're common in Obsidian vaults, but outside Obsidian there's no simple way to print one, share it as a picture or a PDF, or get its text out. Filee now reads them itself (no new dependency) and:

  • draws them as SVG, in the look of Obsidian's light theme, so the vector engine's existing routes give PDF (vector) and PNG/JPG/WebP/…:
    • groups as tinted frames with their label above, plus an optional background picture (cover / ratio / repeat);
    • lines as curves from side to side (fromSide/toSide, or the facing sides when left out), arrow heads (fromEnd/toEnd) and labels;
    • cards in the file's z-order. Text cards render their Markdown simply: headings, bold, italic, strikethrough, ==highlights==, code, bullet/numbered/task lists, quotes, tables, links and [[internal links|aliases]]. File cards show the note (.md, without its properties, or the #heading from subpath) or the picture beside the canvas, otherwise the file's name. Link cards show their address;
    • colours "1"–"6" are Obsidian's light-theme presets, #RRGGBB is used as given;
    • CJK text is measured and drawn with a CJK font (CadFonts, plus a scan of installed families on Linux, where Skia's MatchCharacter finds nothing).
  • writes them as Markdown (cost 15, so PDF/pictures stay the drawing): the board's name as the title, groups as sections, cards from top to bottom (their headings moved below the group's), pictures copied to _files with MarkdownPictures, and the lines at the end (- **A** → **B**: label). DOCX, HTML, TXT and the rest follow from there.

Security, like the rest of Filee (DocumentFiles): a file card's note or picture is read only from the canvas's folder or below. .., absolute paths, UNC/other computers, file:/http: and ~ are refused by their text alone. Vault paths start at the vault root, so if the canvas sits in a subfolder (Vault/Boards/plan.canvas → Boards/img/a.png), they're also looked for without their leading folders, still never above the canvas. Nothing is fetched: link cards only show the URL. Pictures are checked before decoding (50 MB, 100 MP), decoded only by their extension's coder, shrunk to ≤ 2048 px and embedded (≤ 200 per board). SVG pictures go through SvgRenderer's same-origin policy.

Defensive reading (as asked on #1384): budgets are checked before layout: 32 MB file, 10,000 cards, 20,000 lines, coordinates within ±1,000,000, card ≤ 100,000 px, board ≤ 200,000 px a side, 100,000 characters per card, JSON depth 64, SVG ≤ 96 MB. Layout stops at the bottom of each card (clipped, as Obsidian does). Damaged files give a clear InvalidDataException ("The canvas file is damaged: card 3 has no "x"."). Cards of an unknown type and lines to a missing card are skipped, as Obsidian does.

.canvas wasn't taken in FormatRegistry. A .canvas that isn't a JSON Canvas (not JSON, or no nodes/edges array) is refused with error.canvas_not_canvas. One with no cards gets error.canvas_empty, one over budget error.canvas_too_large. All three are in the 22 language files.

Format placement: canvas, read only, category Vector, grade General, field vector (always on). Why Vector rather than document/data/text: a canvas is a drawing of a board, like Visio's VSD (also Vector, General) and the DST/PLT readers that also draw to SVG in VectorConverter. Its useful targets are the vector donut's front (PNG, PDF, SVG, JPG, WebP), whereas the text donut would lead with HWPX/DOCX/EPUB. General: Obsidian is used across fields (students, writers, developers) but isn't a stock OS default. Per FORMAT-RULES rule 5, a read-only format comes in.

Settings schema 87: the vector donut learns .canvas, and custom claims stay put. This may need renumbering if another change takes 87 first.

Type of change

  • Bug fix
  • New feature or improvement
  • New format or engine
  • Translation
  • Documentation
  • Build, CI or dependencies

How was it tested?

  • dotnet format Filee.slnx --verify-no-changes --severity warn
  • dotnet build Filee.slnx -c Release
  • dotnet test: new JsonCanvasTests (Engines, 38 tests: the spec's sample, a board with groups/colours/labelled lines/picture/note, path traversal and absolute/UNC/URL references, markup injection, a huge text card, refused/empty/damaged/over-budget files, deep JSON nesting, colours) and CanvasFormatTests (Core: donut, grade, schema-87 migration). FormatFieldsTests counts and TracingTests.IsTracing updated. Core, Engines and the App translation tests pass, apart from failures that also happen on main in this environment (root-only Core tests, Pandoc/LibreOffice/ISO Engines tests; checked with git stash).
  • Tried it in the app: not in the GUI. Converted with filee-cli on Linux: the jsoncanvas.org sample and a hand-made board → SVG, PNG, PDF, MD (screenshots below).

Not tested: Windows/macOS font fallback for CJK (the code path is the CAD text's), real-world vaults beyond the hand-made boards, the app UI. I compared the style against Obsidian's look and jsoncanvas.org's own stylesheet. I couldn't run another JSON Canvas renderer side by side in my environment.

Screenshots (UI changes)

A test board → PNG: groups, preset and hex colours, labelled lines with arrows, a picture and a note beside the canvas, a link card, task list, table, code, Korean/Japanese text. The two secret.png cards point outside the canvas folder (../ and an absolute path): they are shown by name only, never read.

canvas-tableau-projet

The jsoncanvas.org sample → PNG: the official example, with its notes (readme, spec) and SVG logo beside it.

canvas-exemple-officiel

Checklist

  • New UI strings are in all src/Filee.App/Assets/i18n/*.json files
  • New third-party code or binaries are listed in THIRD-PARTY-NOTICES.md (none added: Markdig, Svg.Skia, SkiaSharp and Magick.NET are already there)
  • The PR does one thing (unrelated changes go in a separate PR)

🤖 Generated with Claude Code

Ugoyo and others added 2 commits November 12, 2026 21:23
…nd Markdown

Obsidian's visual boards (.canvas) follow the open JSON Canvas 1.0 spec.
Filee reads them itself, with no new dependency:

- canvas -> SVG in the look of Obsidian's light theme: groups with their
  labels and backgrounds, curved lines with sides, arrow heads and labels,
  then the cards in z-order. Text cards show their Markdown simply (Markdig),
  file cards show the note or picture beside the canvas, link cards show
  their address. PDF, PNG and the other images come from the vector engine.
- canvas -> Markdown (cost 15): groups as sections, cards in reading order,
  pictures copied to _files, and the lines listed at the end.
- Files are read only from the canvas's folder or below (DocumentFiles);
  "..", absolute, UNC and URL references are refused by their text, and
  nothing is fetched.
- Defensive reading: size, card, line, coordinate, extent and text budgets
  are checked before layout. A .canvas without nodes/edges is refused
  (error.canvas_not_canvas), and so are an empty board and an over-budget
  one. Damaged files give an InvalidDataException.
- Read only, Vector category, general grade. Settings schema 87: the
  vector donut learns .canvas (may need renumbering).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPxh5omWAVqxhGiuyqNWAD
@Ugoyo
Ugoyo requested a review from KnifeLemon as a code owner November 12, 2026 21:58
@github-actions github-actions Bot added the feature New features label Nov 12, 2026

@KnifeLemon KnifeLemon left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thank you for this, @Ugoyo, it's a really thorough reader. The containment rules for file cards (nothing outside the canvas folder, nothing fetched, pictures checked before decoding), the SVG escaping and the budgets checked before layout are all just what we look for, and building every test canvas in code is great. The two screenshots look very close to Obsidian.

A few things to change before we merge, mostly places where one card can get around the budgets:

  1. Cap the vault-path lookup. CanvasFiles.Locate tries the path once for every leading folder it could drop, and for each try it joins the rest and calls DocumentFiles.Find (GetFullPath + File.Exists). The file and background strings have no length limit, and no CancellationToken reaches this loop. So a single file card with "file": "a/a/a/…/x.png" of about 1 MB makes 500,000 filesystem calls on ever-longer strings, and the conversion never finishes, even after Cancel. Could you treat paths over a sane length (say 1,024 characters) as not found, and only drop the first few folders (vault depth above a canvas is small, so 8 or so is plenty)? A test with a long a/a/a/… path that returns quickly would pin it.

  2. Stop long words at the card's bottom, and cut url / file / subpath. In LineBuilder.Word, the letter-by-letter branch for words wider than the card calls Place + Flush for every line without checking owner.Full, and Flush draws whenever it has pieces. So a long unbroken string is laid out to its end as clipped, invisible text. A link card with a multi-MB url (its host falls back to the whole string) is measured letter by letter until the SVG hits 96 MB. A few hundred cards with a pasted base64 blob or hash get the whole board refused as "too large". An owner.Full check in that loop, plus Cut (or a smaller cap) on url, file and subpath in ReadNodes, would fix both.

  3. Decode each picture once, and embed a tiled background once. CanvasFiles.Picture decodes and re-encodes on every call, so 200 cards showing the same large JPEG decode it 200 times. A repeat background writes a separate <image> with the full base64 data for each tile (up to 400), so a 1024 px PNG texture on a 4,000 × 3,000 group already passes the 96 MB budget, and the whole board is refused. Could you cache the encoded picture per path and size, and draw the tiles from one embedded copy (a <pattern>, or one <image> in <defs> reused with <use>, whichever Svg.Skia draws)? As a side note, the tile size is picture.Width / 2, so a texture that wasn't shrunk ends up tiled at half its natural size.

  4. Markdown links with a heading subpath. In CanvasMarkdown.Card, the path segments are escaped but card.Subpath is appended as is. Headings usually have spaces, and a link destination with a space isn't a link in CommonMark, so [Plan](Notes/Plan.md#Next steps) comes out as plain text. "#" + Uri.EscapeDataString(subpath.TrimStart('#')), or wrapping the destination in <…>, fixes it.

  5. Limit group nesting in the Markdown writer. Section recurses once per nested group, and 10,000 concentric groups fit inside the budgets, which is deep enough to risk a stack overflow on a thread-pool thread (that one takes the whole app down). A depth cap like CanvasText.MaxDepth, listing deeper groups at the last level, would be enough.

One small thing, optional: CanvasFonts.FaceFor scans every installed family once for each code point no font has (Private Use Area, unassigned). Skipping that scan for those categories would keep odd text from costing a lot of font lookups.

Nothing to do on your side for the settings schema: #1444 has just taken 87, and several open PRs want the next numbers, so we'll renumber the migration when we merge.

Address the maintainer's review on the JSON Canvas reader, where one card
could get around the budgets:

- CanvasFiles.Locate: treat vault paths over 1,024 characters as not found,
  and drop at most 8 leading folders, so a file card with a megabyte-long
  "a/a/a/.../x.png" no longer makes hundreds of thousands of filesystem
  calls on ever-longer strings. file, url, subpath and background are cut to
  4,096 characters when read.
- CanvasText.LineBuilder: stop the letter-by-letter break for an over-wide
  word at the card's bottom (owner.Full), and don't lay out below it, so a
  multi-MB url or a pasted blob isn't measured to its end as clipped text.
- CanvasFiles: decode and re-encode each picture once per size (cache by
  path and target edge), and keep the natural size for tiling.
- CanvasSvg: embed each picture once in <defs> and draw it with <use>, so a
  photo on many cards and a repeated group background are stored once; tile
  a repeated background at the texture's natural size.
- CanvasMarkdown: escape a file card's heading subpath (#Next%20steps) so
  the link stays a link, and cap group-nesting recursion (listing deeper
  groups iteratively) so concentric groups can't overflow the stack.
- CanvasFonts: skip the installed-family scan for code points no font has
  (private use, unassigned, control, surrogate).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPxh5omWAVqxhGiuyqNWAD
@Ugoyo

Ugoyo commented Nov 13, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough review — all six are addressed in the latest commit.

  • Vault-path lookup is bounded. CanvasFiles.Locate now treats a path over 1,024 characters (MaxPathLength) as not found before touching the disk, and drops at most 8 leading folders (MaxDroppedFolders), so the longest board can make only a handful of DocumentFiles.Find calls per card. file, url, subpath and background are cut to 4,096 characters when read. A test with a megabyte-long a/a/…/x.png now returns in well under a second, and one checks that only the first few folders are dropped.
  • Long words stop at the card's bottom, and refs are capped. The letter-by-letter branch in LineBuilder.Word checks owner.Full each letter, and Flush no longer lays out below the card. With url/file/subpath cut in ReadNodes, a link card with a multi-MB url stays small — new test A_link_card_with_a_huge_url_does_not_blow_the_budget.
  • Each picture is decoded once and embedded once. CanvasFiles.Picture caches the encoded bytes per (path, target size), and CanvasSvg writes each picture once into <defs> and draws it with <use href="#…">. A repeated background now reuses that one copy per tile. Tests: The_same_picture_shown_by_many_cards_of_one_size_is_embedded_once and A_repeated_background_embeds_the_texture_once (a 1024 px texture on a 4000×3000 group stays one copy). Good catch on the tile size too — it now tiles at the texture's natural size (NaturalWidth/Height), not half.
  • Markdown heading subpaths stay links. The destination is now Notes/Plan.md#Next%20steps (escaped subpath) — test A_markdown_file_link_with_a_heading_subpath_stays_a_link.
  • Group nesting in the Markdown writer is bounded. Section recurses only to MaxGroupDepth (16) and lists deeper groups iteratively with an explicit stack, so concentric groups can't overflow. Test builds 5,000 nested groups and checks the leaf card still appears.
  • (optional) Font scan skipped for glyph-less code points. CanvasFonts.FaceFor returns early for private-use, unassigned, control, format and surrogate code points, so they don't scan every installed family.

Tested with dotnet format --verify-no-changes, dotnet build -c Release, and dotnet test (45 JSON Canvas tests, Core canvas/format tests). Thanks also for handling the schema number on your side.

Resolve the settings-schema conflict: main took 87 (Arduino/Processing
sketches) and 88 (NuGet and Android packages), so the .canvas migration
becomes 89. (It may be renumbered again at merge time.) The grade counts
combine both sides: General 98, Specialist 123.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPxh5omWAVqxhGiuyqNWAD
@Ugoyo
Ugoyo requested a review from KnifeLemon November 13, 2026 18:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants