Repository navigation
Conversation
…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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BPxh5omWAVqxhGiuyqNWAD
KnifeLemon
left a comment
There was a problem hiding this comment.
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:
-
Cap the vault-path lookup.
CanvasFiles.Locatetries the path once for every leading folder it could drop, and for each try it joins the rest and callsDocumentFiles.Find(GetFullPath+File.Exists). Thefileandbackgroundstrings have no length limit, and noCancellationTokenreaches 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 longa/a/a/…path that returns quickly would pin it. -
Stop long words at the card's bottom, and cut
url/file/subpath. InLineBuilder.Word, the letter-by-letter branch for words wider than the card callsPlace+Flushfor every line without checkingowner.Full, andFlushdraws 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-MBurl(itshostfalls 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". Anowner.Fullcheck in that loop, plusCut(or a smaller cap) onurl,fileandsubpathinReadNodes, would fix both. -
Decode each picture once, and embed a tiled background once.
CanvasFiles.Picturedecodes and re-encodes on every call, so 200 cards showing the same large JPEG decode it 200 times. Arepeatbackground 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 ispicture.Width / 2, so a texture that wasn't shrunk ends up tiled at half its natural size. -
Markdown links with a heading subpath. In
CanvasMarkdown.Card, the path segments are escaped butcard.Subpathis 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. -
Limit group nesting in the Markdown writer.
Sectionrecurses 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 likeCanvasText.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
|
Thanks for the thorough review — all six are addressed in the latest commit.
Tested with |
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
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:cover/ratio/repeat);fromSide/toSide, or the facing sides when left out), arrow heads (fromEnd/toEnd) and labels;==highlights==, code, bullet/numbered/task lists, quotes, tables, links and[[internal links|aliases]]. File cards show the note (.md, without its properties, or the#headingfromsubpath) or the picture beside the canvas, otherwise the file's name. Link cards show their address;"1"–"6"are Obsidian's light-theme presets,#RRGGBBis used as given;MatchCharacterfinds nothing)._fileswithMarkdownPictures, 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 throughSvgRenderer'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..canvaswasn't taken inFormatRegistry. A.canvasthat isn't a JSON Canvas (not JSON, or nonodes/edgesarray) is refused witherror.canvas_not_canvas. One with no cards getserror.canvas_empty, one over budgeterror.canvas_too_large. All three are in the 22 language files.Format placement:
canvas, read only, category Vector, grade General, fieldvector(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 inVectorConverter. 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
How was it tested?
dotnet format Filee.slnx --verify-no-changes --severity warndotnet build Filee.slnx -c Releasedotnet test: newJsonCanvasTests(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) andCanvasFormatTests(Core: donut, grade, schema-87 migration).FormatFieldsTestscounts andTracingTests.IsTracingupdated. 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 withgit stash).filee-clion 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.pngcards point outside the canvas folder (../and an absolute path): they are shown by name only, never read.The jsoncanvas.org sample → PNG: the official example, with its notes (readme, spec) and SVG logo beside it.
Checklist
src/Filee.App/Assets/i18n/*.jsonfilesTHIRD-PARTY-NOTICES.md(none added: Markdig, Svg.Skia, SkiaSharp and Magick.NET are already there)🤖 Generated with Claude Code