From 0a5eceedf191ccdd800a7d60b38b7d99be412d16 Mon Sep 17 00:00:00 2001 From: Patrik Lermon Date: Thu, 15 Oct 2026 22:05:47 +0200 Subject: [PATCH 001/118] i18n(sv): translate 480 new strings Brings the Swedish catalog up to date with upstream main (eb741a3). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/sv.toml | 480 +++++++++++++++++++++++++++++++++ 1 file changed, 480 insertions(+) diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index f7b2999d..195c94af 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -16,6 +16,7 @@ name = "Svenska" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR:er, {commits} commits, +{added} / −{deleted} rader (Δ {delta}), +{bin_added} / −{bin_deleted} binära tillgångar, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "En samling i den här filen med dessa lägen och variabler: åsidosätt deras värden per läge och lägg till egna variabler" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Ett ensamt lager justeras mot sin ram: markera fler lager, eller ett lager i en ram" "A responsive product page: hero, features, figures, a quote and a footer" = "En responsiv produktsida: toppsektion, funktioner, figurer, ett citat och en sidfot" "A spring plays until it settles: its mass, stiffness and damping set how long" = "En fjäder spelas tills den stannar: dess massa, styvhet och dämpning avgör hur länge" "A title, an agenda, a big figure, a chart and a closing slide" = "En titel, en agenda, en stor figur, ett diagram och en avslutande bild" @@ -309,7 +310,9 @@ name = "Svenska" "Copy {value}" = "Kopiera {value}" "Corner radius" = "Hörnradie" "Corner smoothing" = "Hörnutjämning" +"Couldn't download the variables" = "Det gick inte att hämta variablerna" "Couldn't reach {server}: showing what this computer remembers." = "Det gick inte att nå {server}: visar det som den här datorn minns." +"Couldn't save automatically: {error}" = "Det gick inte att spara automatiskt: {error}" "Count" = "Antal" "Create" = "Skapa" "Create a variable collection first" = "Skapa en variabelsamling först" @@ -474,6 +477,7 @@ name = "Svenska" "False" = "Falskt" "Feb" = "feb" "Fetching from the server…" = "Hämtar från servern…" +"FigCraft couldn't open its window: {error}" = "FigCraft kunde inte öppna sitt fönster: {error}" "File" = "Fil" "File name" = "Filnamn" "File not found" = "Filen hittades inte" @@ -593,6 +597,7 @@ name = "Svenska" "Hug contents" = "Omslut innehåll" "Icon components on a 24 px grid with keylines and a size preview" = "Ikonkomponenter på ett 24 px-rutnät med hjälplinjer och en förhandsgranskning av storlekar" "Icon set starter" = "Startpaket för ikonuppsättning" +"If it keeps happening, please report it at {link} with this message." = "Om det fortsätter att hända kan du rapportera det på {link} och bifoga det här meddelandet." "If" = "Om" "If {condition}" = "Om {condition}" "Ignore auto layout (absolute position)" = "Ignorera automatisk layout (absolut position)" @@ -1045,6 +1050,7 @@ name = "Svenska" "Remove the else and its actions" = "Ta bort else-grenen och dess åtgärder" "Remove this condition and its actions" = "Ta bort det här villkoret och dess åtgärder" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Vill du ta bort den här filen från servern? Den kan inte längre delas eller öppnas därifrån. Din kopia förblir öppen här." +"Remove {name} from session" = "Ta bort {name} från sessionen" "Remove {name} from {server}?" = "Vill du ta bort {name} från {server}?" "Removed" = "Borttaget" "Removed from the server" = "Borttagen från servern" @@ -1112,6 +1118,7 @@ name = "Svenska" "Rulers" = "Linjaler" "Run" = "Kör" "Run {label}" = "Kör {label}" +"Save files automatically" = "Spara filer automatiskt" "Search templates" = "Sök mallar" "See all templates" = "Se alla mallar" "Select layers and they space themselves as their content changes" = "Markera lager så fördelar de avståndet själva när innehållet ändras" @@ -1388,6 +1395,8 @@ name = "Svenska" "Update from selection" = "Uppdatera från markering" "Updated {components} and {styles}" = "{components} och {styles} har uppdaterats" "Updated {name}" = "{name} har uppdaterats" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Det hjälper ofta att uppdatera grafikdrivrutinen. Om du bara vill använda OpenGL startar du FigCraft från en kommandotolk med:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Det hjälper ofta att uppdatera grafikdrivrutinen. WGPU_BACKEND (vulkan, gl, metal) anger ett annat grafik-API att prova." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Laddar upp filen till en FigCraft-server och håller fliken synkroniserad med den. Du får en länk för redigering och en för visning." "Uppercase" = "Versaler" "Use a fixed value" = "Använd ett fast värde" @@ -1430,6 +1439,7 @@ name = "Svenska" "Visibility" = "Synlighet" "Visible" = "Synlig" "Vivid" = "Livfull" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND är satt och anger vilket grafik-API som ska användas; tar du bort den väljer FigCraft själv." "Warm" = "Varm" "Watch" = "Bevaka" "Wavy" = "Vågig" @@ -1588,6 +1598,127 @@ name = "Svenska" [commands] +"align.bottom" = "align.bottom" +"align.hcenter" = "align.hcenter" +"align.left" = "align.left" +"align.right" = "align.right" +"align.top" = "align.top" +"align.vcenter" = "align.vcenter" +"annotation.add" = "annotation.add" +"annotation.addCategory" = "annotation.addCategory" +"annotation.categories" = "annotation.categories" +"annotation.edit" = "annotation.edit" +"annotation.editCategory" = "annotation.editCategory" +"annotation.list" = "annotation.list" +"annotation.remove" = "annotation.remove" +"annotation.removeCategory" = "annotation.removeCategory" +"app.actions" = "app.actions" +"app.preferences" = "app.preferences" +"arrange.back" = "arrange.back" +"arrange.backward" = "arrange.backward" +"arrange.forward" = "arrange.forward" +"arrange.front" = "arrange.front" +"arrange.packH" = "arrange.packH" +"arrange.packV" = "arrange.packV" +"arrange.roundToPixels" = "arrange.roundToPixels" +"arrange.tidy" = "arrange.tidy" +"bool.exclude" = "bool.exclude" +"bool.flatten" = "bool.flatten" +"bool.intersect" = "bool.intersect" +"bool.subtract" = "bool.subtract" +"bool.union" = "bool.union" +"branch.archive" = "branch.archive" +"branch.create" = "branch.create" +"branch.list" = "branch.list" +"branch.merge" = "branch.merge" +"branch.open" = "branch.open" +"branch.review" = "branch.review" +"branch.updateFromMain" = "branch.updateFromMain" +"codeConnect.export" = "codeConnect.export" +"codeConnect.import" = "codeConnect.import" +"codeConnect.remove" = "codeConnect.remove" +"codeConnect.set" = "codeConnect.set" +"collab.cursor" = "collab.cursor" +"collab.cursorChat" = "collab.cursorChat" +"collab.diff" = "collab.diff" +"collab.files" = "collab.files" +"collab.follow" = "collab.follow" +"collab.forgetFile" = "collab.forgetFile" +"collab.host" = "collab.host" +"collab.join" = "collab.join" +"collab.leave" = "collab.leave" +"collab.manageFile" = "collab.manageFile" +"collab.openFile" = "collab.openFile" +"collab.peers" = "collab.peers" +"collab.publish" = "collab.publish" +"collab.recoverFile" = "collab.recoverFile" +"collab.registerLinks" = "collab.registerLinks" +"collab.removePerson" = "collab.removePerson" +"collab.report" = "collab.report" +"collab.serverInfo" = "collab.serverInfo" +"collab.share" = "collab.share" +"collab.spotlight" = "collab.spotlight" +"collab.stop" = "collab.stop" +"command.batch" = "command.batch" +"comment.add" = "comment.add" +"comment.delete" = "comment.delete" +"comment.edit" = "comment.edit" +"comment.link" = "comment.link" +"comment.list" = "comment.list" +"comment.markRead" = "comment.markRead" +"comment.move" = "comment.move" +"comment.next" = "comment.next" +"comment.open" = "comment.open" +"comment.people" = "comment.people" +"comment.react" = "comment.react" +"comment.reply" = "comment.reply" +"comment.resolve" = "comment.resolve" +"comment.zoom" = "comment.zoom" +"component.addProperty" = "component.addProperty" +"component.addVariant" = "component.addVariant" +"component.bindProperty" = "component.bindProperty" +"component.combineVariants" = "component.combineVariants" +"component.create" = "component.create" +"component.createMultiple" = "component.createMultiple" +"component.deleteProperty" = "component.deleteProperty" +"component.detach" = "component.detach" +"component.editProperty" = "component.editProperty" +"component.exposeInstance" = "component.exposeInstance" +"component.goToMain" = "component.goToMain" +"component.hideWhenPublishing" = "component.hideWhenPublishing" +"component.list" = "component.list" +"component.makeSlot" = "component.makeSlot" +"component.playground" = "component.playground" +"component.properties" = "component.properties" +"component.pushChanges" = "component.pushChanges" +"component.removeSlot" = "component.removeSlot" +"component.resetInstance" = "component.resetInstance" +"component.resetOverride" = "component.resetOverride" +"component.restoreMain" = "component.restoreMain" +"dev.compare" = "dev.compare" +"dev.focus" = "dev.focus" +"dev.resolveChange" = "dev.resolveChange" +"dev.unfocus" = "dev.unfocus" +"devResource.add" = "devResource.add" +"devResource.edit" = "devResource.edit" +"devResource.list" = "devResource.list" +"devResource.remove" = "devResource.remove" +"distribute.horizontal" = "distribute.horizontal" +"distribute.vertical" = "distribute.vertical" +"doc.home" = "doc.home" +"doc.list" = "doc.list" +"doc.nextTab" = "doc.nextTab" +"doc.previousTab" = "doc.previousTab" +"doc.switch" = "doc.switch" +"edit.copy" = "edit.copy" +"edit.copyAsCss" = "edit.copyAsCss" +"edit.copyAsPng" = "edit.copyAsPng" +"edit.copyAsSvg" = "edit.copyAsSvg" +"edit.copyAsText" = "edit.copyAsText" +"edit.copyLink" = "edit.copyLink" +"edit.copyProperties" = "edit.copyProperties" +"edit.cut" = "edit.cut" +"edit.delete" = "edit.delete" "edit.duplicate" = "Duplicera" "edit.findReplace" = "Sök och ersätt" "edit.paste" = "Klistra in" @@ -1708,6 +1839,355 @@ name = "Svenska" "object.moveToPage" = "Flytta till sida" "object.opacityKeys" = "Nyckelbildrutor för opacitet" "object.rasterize" = "Rastrera" +"object.releaseMask" = "object.releaseMask" +"object.removeFill" = "object.removeFill" +"object.removeFrame" = "object.removeFrame" +"object.removeStroke" = "object.removeStroke" +"object.rename" = "object.rename" +"object.resizeToFit" = "object.resizeToFit" +"object.rotate180" = "object.rotate180" +"object.rotateLeft" = "object.rotateLeft" +"object.rotateRight" = "object.rotateRight" +"object.setThumbnail" = "object.setThumbnail" +"object.swapFillStroke" = "object.swapFillStroke" +"object.toggleLock" = "object.toggleLock" +"object.toggleVisible" = "object.toggleVisible" +"object.ungroup" = "object.ungroup" +"object.wrapInSection" = "object.wrapInSection" +"page.add" = "page.add" +"page.delete" = "page.delete" +"page.duplicate" = "page.duplicate" +"page.list" = "page.list" +"page.move" = "page.move" +"page.rename" = "page.rename" +"page.select" = "page.select" +"page.setBackground" = "page.setBackground" +"paint.documentColors" = "paint.documentColors" +"plugin.close" = "plugin.close" +"plugin.directory.install" = "plugin.directory.install" +"plugin.directory.list" = "plugin.directory.list" +"plugin.directory.publish" = "plugin.directory.publish" +"plugin.directory.unpublish" = "plugin.directory.unpublish" +"plugin.event" = "plugin.event" +"plugin.import" = "plugin.import" +"plugin.list" = "plugin.list" +"plugin.remove" = "plugin.remove" +"plugin.run" = "plugin.run" +"plugin.ui" = "plugin.ui" +"plugins.manage" = "plugins.manage" +"plugins.runLast" = "plugins.runLast" +"pref.autoSave" = "pref.autoSave" +"pref.highlightHover" = "pref.highlightHover" +"pref.keepTool" = "pref.keepTool" +"pref.language" = "pref.language" +"pref.nudge" = "pref.nudge" +"pref.numberKeysOpacity" = "pref.numberKeysOpacity" +"pref.publishServer" = "pref.publishServer" +"pref.reduceMotion" = "pref.reduceMotion" +"pref.renameDuplicates" = "pref.renameDuplicates" +"pref.showDimensions" = "pref.showDimensions" +"pref.userName" = "pref.userName" +"proto.action.add" = "proto.action.add" +"proto.action.move" = "proto.action.move" +"proto.action.remove" = "proto.action.remove" +"proto.action.set" = "proto.action.set" +"proto.closePreview" = "proto.closePreview" +"proto.conditional.addBlock" = "proto.conditional.addBlock" +"proto.conditional.removeBlock" = "proto.conditional.removeBlock" +"proto.conditional.setCondition" = "proto.conditional.setCondition" +"proto.evaluate" = "proto.evaluate" +"proto.flow.add" = "proto.flow.add" +"proto.flow.remove" = "proto.flow.remove" +"proto.flow.rename" = "proto.flow.rename" +"proto.interaction.add" = "proto.interaction.add" +"proto.interaction.clear" = "proto.interaction.clear" +"proto.interaction.remove" = "proto.interaction.remove" +"proto.interaction.set" = "proto.interaction.set" +"proto.present" = "proto.present" +"proto.presentOptions" = "proto.presentOptions" +"proto.preview" = "proto.preview" +"proto.settings" = "proto.settings" +"section.toggleContents" = "section.toggleContents" +"select.add" = "select.add" +"select.all" = "select.all" +"select.area" = "select.area" +"select.byName" = "select.byName" +"select.children" = "select.children" +"select.inverse" = "select.inverse" +"select.layersAt" = "select.layersAt" +"select.matching" = "select.matching" +"select.next" = "select.next" +"select.none" = "select.none" +"select.parent" = "select.parent" +"select.previous" = "select.previous" +"select.remove" = "select.remove" +"select.sameEffect" = "select.sameEffect" +"select.sameFill" = "select.sameFill" +"select.sameFont" = "select.sameFont" +"select.sameInstance" = "select.sameInstance" +"select.sameProperties" = "select.sameProperties" +"select.sameStroke" = "select.sameStroke" +"select.sameText" = "select.sameText" +"select.set" = "select.set" +"shape.arrow" = "shape.arrow" +"shape.ellipse" = "shape.ellipse" +"shape.line" = "shape.line" +"shape.polygon" = "shape.polygon" +"shape.rectangle" = "shape.rectangle" +"shape.star" = "shape.star" +"shapeBuilder.merge" = "shapeBuilder.merge" +"shapeBuilder.mode" = "shapeBuilder.mode" +"shapeBuilder.regions" = "shapeBuilder.regions" +"shapeBuilder.remove" = "shapeBuilder.remove" +"stroke.add" = "stroke.add" +"stroke.brush" = "stroke.brush" +"stroke.move" = "stroke.move" +"stroke.remove" = "stroke.remove" +"stroke.set" = "stroke.set" +"stroke.widthProfile" = "stroke.widthProfile" +"style.apply" = "style.apply" +"style.bind" = "style.bind" +"style.create" = "style.create" +"style.delete" = "style.delete" +"style.detach" = "style.detach" +"style.duplicate" = "style.duplicate" +"style.list" = "style.list" +"style.move" = "style.move" +"style.renameFolder" = "style.renameFolder" +"style.update" = "style.update" +"template.list" = "template.list" +"text.alignCenter" = "text.alignCenter" +"text.alignJustify" = "text.alignJustify" +"text.alignLeft" = "text.alignLeft" +"text.alignRight" = "text.alignRight" +"text.bold" = "text.bold" +"text.bulletList" = "text.bulletList" +"text.case" = "text.case" +"text.caseLower" = "text.caseLower" +"text.caseOriginal" = "text.caseOriginal" +"text.caseSmallCaps" = "text.caseSmallCaps" +"text.caseTitle" = "text.caseTitle" +"text.caseUpper" = "text.caseUpper" +"text.create" = "text.create" +"text.decoration" = "text.decoration" +"text.edit.begin" = "text.edit.begin" +"text.edit.caret" = "text.edit.caret" +"text.edit.end" = "text.edit.end" +"text.edit.insert" = "text.edit.insert" +"text.edit.key" = "text.edit.key" +"text.edit.preedit" = "text.edit.preedit" +"text.edit.selectLine" = "text.edit.selectLine" +"text.edit.selectWord" = "text.edit.selectWord" +"text.edit.selection" = "text.edit.selection" +"text.endPreview" = "text.endPreview" +"text.fonts" = "text.fonts" +"text.italic" = "text.italic" +"text.lineHeightDown" = "text.lineHeightDown" +"text.lineHeightUp" = "text.lineHeightUp" +"text.link" = "text.link" +"text.listIndent" = "text.listIndent" +"text.listOutdent" = "text.listOutdent" +"text.listStyle" = "text.listStyle" +"text.maxLines" = "text.maxLines" +"text.numberList" = "text.numberList" +"text.onPath" = "text.onPath" +"text.paragraphIndent" = "text.paragraphIndent" +"text.paragraphSpacing" = "text.paragraphSpacing" +"text.pathOptions" = "text.pathOptions" +"text.preview" = "text.preview" +"text.removeFromPath" = "text.removeFromPath" +"text.set" = "text.set" +"text.setFeatures" = "text.setFeatures" +"text.setVariations" = "text.setVariations" +"text.sizeDown" = "text.sizeDown" +"text.sizeUp" = "text.sizeUp" +"text.strikethrough" = "text.strikethrough" +"text.style" = "text.style" +"text.trackingDown" = "text.trackingDown" +"text.trackingUp" = "text.trackingUp" +"text.underline" = "text.underline" +"text.weightDown" = "text.weightDown" +"text.weightUp" = "text.weightUp" +"tool.arrow" = "tool.arrow" +"tool.comment" = "tool.comment" +"tool.ellipse" = "tool.ellipse" +"tool.frame" = "tool.frame" +"tool.hand" = "tool.hand" +"tool.line" = "tool.line" +"tool.move" = "tool.move" +"tool.pen" = "tool.pen" +"tool.pencil" = "tool.pencil" +"tool.pencilBrush" = "tool.pencilBrush" +"tool.place" = "tool.place" +"tool.polygon" = "tool.polygon" +"tool.rectangle" = "tool.rectangle" +"tool.scale" = "tool.scale" +"tool.scaleAnchor" = "tool.scaleAnchor" +"tool.section" = "tool.section" +"tool.set" = "tool.set" +"tool.shapeBuilder" = "tool.shapeBuilder" +"tool.slice" = "tool.slice" +"tool.star" = "tool.star" +"tool.text" = "tool.text" +"transform.move" = "transform.move" +"transform.moveTo" = "transform.moveTo" +"transform.nudge" = "transform.nudge" +"transform.nudgeSize" = "transform.nudgeSize" +"transform.resize" = "transform.resize" +"transform.rotate" = "transform.rotate" +"transform.rotationOrigin" = "transform.rotationOrigin" +"transform.scale" = "transform.scale" +"transform.setRotationOrigin" = "transform.setRotationOrigin" +"variable.addMode" = "variable.addMode" +"variable.bind" = "variable.bind" +"variable.create" = "variable.create" +"variable.createCollection" = "variable.createCollection" +"variable.delete" = "variable.delete" +"variable.deleteCollection" = "variable.deleteCollection" +"variable.duplicate" = "variable.duplicate" +"variable.export" = "variable.export" +"variable.import" = "variable.import" +"variable.list" = "variable.list" +"variable.moveMode" = "variable.moveMode" +"variable.removeMode" = "variable.removeMode" +"variable.renameCollection" = "variable.renameCollection" +"variable.renameMode" = "variable.renameMode" +"variable.resetOverride" = "variable.resetOverride" +"variable.set" = "variable.set" +"variable.setMode" = "variable.setMode" +"variable.update" = "variable.update" +"vector.create" = "vector.create" +"vector.deleteHeal" = "vector.deleteHeal" +"vector.edit.addVertex" = "vector.edit.addVertex" +"vector.edit.begin" = "vector.edit.begin" +"vector.edit.bend" = "vector.edit.bend" +"vector.edit.bounds" = "vector.edit.bounds" +"vector.edit.connect" = "vector.edit.connect" +"vector.edit.cornerRadius" = "vector.edit.cornerRadius" +"vector.edit.cut" = "vector.edit.cut" +"vector.edit.delete" = "vector.edit.delete" +"vector.edit.end" = "vector.edit.end" +"vector.edit.erase" = "vector.edit.erase" +"vector.edit.lasso" = "vector.edit.lasso" +"vector.edit.mirroring" = "vector.edit.mirroring" +"vector.edit.moveHandles" = "vector.edit.moveHandles" +"vector.edit.moveVertices" = "vector.edit.moveVertices" +"vector.edit.nudge" = "vector.edit.nudge" +"vector.edit.paint" = "vector.edit.paint" +"vector.edit.pen" = "vector.edit.pen" +"vector.edit.penEnd" = "vector.edit.penEnd" +"vector.edit.position" = "vector.edit.position" +"vector.edit.resize" = "vector.edit.resize" +"vector.edit.rotate" = "vector.edit.rotate" +"vector.edit.select" = "vector.edit.select" +"vector.edit.selectAll" = "vector.edit.selectAll" +"vector.edit.setHandle" = "vector.edit.setHandle" +"vector.edit.smooth" = "vector.edit.smooth" +"vector.edit.state" = "vector.edit.state" +"vector.edit.strokeCap" = "vector.edit.strokeCap" +"vector.edit.strokeJoin" = "vector.edit.strokeJoin" +"vector.edit.tool" = "vector.edit.tool" +"vector.edit.transform" = "vector.edit.transform" +"vector.edit.widthPoint" = "vector.edit.widthPoint" +"vector.flatten" = "vector.flatten" +"vector.join" = "vector.join" +"vector.offset" = "vector.offset" +"vector.outlineStroke" = "vector.outlineStroke" +"vector.reverse" = "vector.reverse" +"vector.simplify" = "vector.simplify" +"vector.smoothJoin" = "vector.smoothJoin" +"version.autosave" = "version.autosave" +"version.delete" = "version.delete" +"version.duplicate" = "version.duplicate" +"version.exitPreview" = "version.exitPreview" +"version.list" = "version.list" +"version.preview" = "version.preview" +"version.rename" = "version.rename" +"version.restore" = "version.restore" +"view.assets" = "view.assets" +"view.comments" = "view.comments" +"view.contextMenu" = "view.contextMenu" +"view.design" = "view.design" +"view.designPrototype" = "view.designPrototype" +"view.devMode" = "view.devMode" +"view.frameOutlines" = "view.frameOutlines" +"view.fullscreen" = "view.fullscreen" +"view.get" = "view.get" +"view.layers" = "view.layers" +"view.layoutGrids" = "view.layoutGrids" +"view.leftSidebar" = "view.leftSidebar" +"view.maskOutlines" = "view.maskOutlines" +"view.multiplayerCursors" = "view.multiplayerCursors" +"view.nextFrame" = "view.nextFrame" +"view.nextPage" = "view.nextPage" +"view.nextRegion" = "view.nextRegion" +"view.outlines" = "view.outlines" +"view.outlinesHiddenLayers" = "view.outlinesHiddenLayers" +"view.outlinesObjectBounds" = "view.outlinesObjectBounds" +"view.outlinesSelection" = "view.outlinesSelection" +"view.pan" = "view.pan" +"view.panelWidths" = "view.panelWidths" +"view.pixelGrid" = "view.pixelGrid" +"view.pixelPreview" = "view.pixelPreview" +"view.prevFrame" = "view.prevFrame" +"view.prevPage" = "view.prevPage" +"view.previousRegion" = "view.previousRegion" +"view.proofSrgb" = "view.proofSrgb" +"view.prototype" = "view.prototype" +"view.rulers" = "view.rulers" +"view.set" = "view.set" +"view.slices" = "view.slices" +"view.snapGeometry" = "view.snapGeometry" +"view.snapObjects" = "view.snapObjects" +"view.snapPixel" = "view.snapPixel" +"view.themeDark" = "view.themeDark" +"view.themeLight" = "view.themeLight" +"view.themeSystem" = "view.themeSystem" +"view.toggleUi" = "view.toggleUi" +"view.zoom100" = "view.zoom100" +"view.zoom200" = "view.zoom200" +"view.zoom50" = "view.zoom50" +"view.zoomAt" = "view.zoomAt" +"view.zoomFit" = "view.zoomFit" +"view.zoomIn" = "view.zoomIn" +"view.zoomOut" = "view.zoomOut" +"view.zoomSelection" = "view.zoomSelection" +"widget.click" = "widget.click" +"widget.insert" = "widget.insert" +"widget.list" = "widget.list" +"window.focus" = "window.focus" +"window.new" = "window.new" [menu] +"bool.exclude" = "bool.exclude" +"bool.flatten" = "bool.flatten" +"bool.intersect" = "bool.intersect" +"bool.subtract" = "bool.subtract" +"bool.union" = "bool.union" +"file.duplicate" = "file.duplicate" +"file.import" = "file.import" +"file.rename" = "file.rename" +"libraries.open" = "libraries.open" +"select.sameEffect" = "select.sameEffect" +"select.sameFill" = "select.sameFill" +"select.sameFont" = "select.sameFont" +"select.sameInstance" = "select.sameInstance" +"select.sameProperties" = "select.sameProperties" +"select.sameStroke" = "select.sameStroke" +"select.sameText" = "select.sameText" +"text.alignCenter" = "text.alignCenter" +"text.alignJustify" = "text.alignJustify" +"text.alignLeft" = "text.alignLeft" +"text.alignRight" = "text.alignRight" +"view.assets" = "view.assets" +"view.design" = "view.design" +"view.layers" = "view.layers" +"view.outlines" = "view.outlines" +"view.outlinesHiddenLayers" = "view.outlinesHiddenLayers" +"view.outlinesObjectBounds" = "view.outlinesObjectBounds" +"view.outlinesSelection" = "view.outlinesSelection" +"view.prototype" = "view.prototype" +"view.themeDark" = "view.themeDark" +"view.themeLight" = "view.themeLight" +"view.themeSystem" = "view.themeSystem" From 0404448f74f762c6997a31936eb5e8289b223563 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 00:54:33 +0200 Subject: [PATCH 002/118] M1: keep imported file bytes out of the command journal Pasting or dropping a file runs file.import with the whole file as Base64 data, and every successful command was journaled with its parameters, so each imported file stayed in memory a second time for the session. Byte imports, including ones wrapped in command.batch, are no longer journaled. Imports by path and all other commands are unchanged. --- crates/engine/src/import_journal_tests.rs | 51 +++++++++++++++++++++++ crates/engine/src/lib.rs | 28 +++++++++++-- 2 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/import_journal_tests.rs diff --git a/crates/engine/src/import_journal_tests.rs b/crates/engine/src/import_journal_tests.rs new file mode 100644 index 00000000..f8fa84ef --- /dev/null +++ b/crates/engine/src/import_journal_tests.rs @@ -0,0 +1,51 @@ +//! Imported file bytes already live in the document: the command log keeps no second copy. + +use base64::Engine as _; +use serde_json::{Value, json}; + +use crate::Session; + +const SVG: &str = r##""##; + +fn payload() -> String { + base64::engine::general_purpose::STANDARD.encode(SVG) +} + +fn holds_payload(journal: &[(String, Value)]) -> bool { + let payload = payload(); + journal.iter().any(|(_, params)| params.to_string().contains(&payload)) +} + +#[test] +fn byte_imports_keep_no_payload_in_the_journal() { + let mut s = Session::new(); + let before = s.doc().unwrap().node_count(); + s.execute("file.import", &json!({"data": payload(), "name": "pasted.svg"})).unwrap(); + assert!(!holds_payload(&s.journal), "the journal must not retain the Base64 file"); + // Not journaling is no change to the edit itself: still one undo step. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().node_count(), before); +} + +#[test] +fn batch_wrappers_do_not_retain_import_payloads() { + let mut s = Session::new(); + let nested = + json!({"command": "command.batch", "params": {"commands": [{"command": "file.import", "params": {"data": payload(), "name": "a.svg"}}]}}); + s.execute("command.batch", &json!({"commands": [nested]})).unwrap(); + assert!(!holds_payload(&s.journal)); +} + +#[test] +fn path_imports_and_ordinary_edits_are_still_journaled() { + let dir = std::env::temp_dir().join(format!("figcraft-import-journal-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("kit.svg"); + std::fs::write(&path, SVG).unwrap(); + let mut s = Session::new(); + s.execute("file.import", &json!({"path": path.to_string_lossy()})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let ids: Vec<_> = s.journal.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(ids, ["file.import", "shape.rectangle"]); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..b84012a6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -721,7 +721,8 @@ pub struct Session { /// dark page colour while it is dark (added pages take the current page's). Headless sessions (CLI, MCP, exports) have no /// theme, so what scripts make is the same everywhere. pub ui_dark: Option, - /// Command log (id + params) for actions/automation. + /// Command log (id + params) for actions/automation. File imports that carry the file bytes + /// (`file.import` with `data`) are not logged, so the log never holds a copy of a pasted file. pub journal: Vec<(String, Value)>, /// Recently used colours. pub recent_colors: Vec, @@ -746,6 +747,19 @@ impl Default for Session { } } +/// Whether a command, or a batch wrapping one, imports a file passed as Base64 `data`. +fn command_carries_import_bytes(id: &str, params: &Value) -> bool { + match id { + "file.import" => params::get(params, "data").is_some(), + "command.batch" => params.get("commands").and_then(Value::as_array).is_some_and(|commands| { + commands.iter().any(|c| { + c.get("command").and_then(Value::as_str).zip(c.get("params")).is_some_and(|(id, params)| command_carries_import_bytes(id, params)) + }) + }), + _ => false, + } +} + impl Session { /// A session with one new document. pub fn new() -> Self { @@ -885,9 +899,13 @@ impl Session { if spec.history && id != "edit.repeat" { self.last_command = Some((id.to_string(), params.clone())); } - self.journal.push((id.to_string(), params.clone())); - if self.journal.len() > 5000 { - self.journal.drain(..1000); + // Imported bytes already live in the document: a Base64 copy in the log would stay + // for the whole session. + if !command_carries_import_bytes(id, params) { + self.journal.push((id.to_string(), params.clone())); + if self.journal.len() > 5000 { + self.journal.drain(..1000); + } } } Err(_) => { @@ -1550,6 +1568,8 @@ mod html_codegen_tests; #[cfg(test)] mod import_export_tests; #[cfg(test)] +mod import_journal_tests; +#[cfg(test)] mod inspect_behaviour_tests; #[cfg(test)] mod interaction_rollback_tests; From 924310c124a6662d8e54c8b6acd45e443dab6b76 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Thu, 15 Oct 2026 23:14:59 +0200 Subject: [PATCH 003/118] M1: keep per-point stroke joins on .fig import Vector points carry their own stroke join in a .fig file. Import now keeps each point's join as the point's own override instead of dropping it for the layer's join. Joins equal to the layer join no longer force styled stroke runs, and an absent layer join counts as miter. Unresolved point styles, unknown joins, and joins that dashed or variable-width strokes cannot draw each produce a warning. --- crates/format/src/fig/map.rs | 28 ++++++++++++++-- crates/format/src/fig/tail_tests.rs | 52 ++++++++++++++++++++++++++--- crates/format/src/fig/tests.rs | 2 +- crates/pathops/src/lib.rs | 44 ++++++++++++++++++++++++ crates/pathops/src/points.rs | 11 ++++-- docs/file-format-parity.md | 2 +- 6 files changed, 128 insertions(+), 11 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 66a00129..914843d3 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -899,12 +899,20 @@ impl<'a> Mapper<'a> { size.zip(norm).filter(|(s, n)| s.is_finite() && *n > 1e-9).map_or(1.0, |(s, n)| s / n) }; let (sx, sy) = (scale("x"), scale("y")); + // An absent layer join is the source default. + let layer_join = v.str("strokeJoin").unwrap_or("MITER"); + let mut unsupported_join = false; + let mut unresolved_style = false; + let mut unknown_join = false; let vertices: Vec = net .vertices .iter() .map(|p| { let mut out = json!({"x": p.x * sx, "y": p.y * sy}); let st = style(p.style); + if p.style != 0 && st.is_none() { + unresolved_style = true; + } if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) { out["radius"] = json!(r); } @@ -914,6 +922,15 @@ impl<'a> Mapper<'a> { Some("NONE") => out["mirror"] = json!("none"), _ => {} } + if let Some(raw) = st.and_then(|s| s.get("strokeJoin")) { + match raw.str() { + Some(join @ ("MITER" | "BEVEL" | "ROUND")) => { + out["join"] = json!(join.to_ascii_lowercase()); + unsupported_join |= join != layer_join; + } + _ => unknown_join = true, + } + } out }) .collect(); @@ -924,9 +941,14 @@ impl<'a> Mapper<'a> { if net.regions.iter().any(|r| style(r.style).is_some_and(|s| s.get("fillPaints").is_some())) { self.warn("filled areas of a vector with their own fill use the layer's fill"); } - let join = v.str("strokeJoin"); - if net.vertices.iter().any(|p| style(p.style).and_then(|s| s.get("strokeJoin")).and_then(Kv::str).is_some_and(|j| Some(j) != join)) { - self.warn("points with their own stroke join use the layer's"); + if unresolved_style { + self.warn("vector points with unresolved style IDs lost their point styles"); + } + if unknown_join { + self.warn("vector points with unknown stroke joins use the layer join"); + } + if unsupported_join && (o.contains_key("strokeDashes") || o.contains_key("strokeWidthProfile")) { + self.warn("point stroke joins were preserved; dashed or variable-width strokes render with the layer join"); } // The stroke starts at the first end point and stops at the last (as the editor draws it). let mut degree = vec![0usize; net.vertices.len()]; diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index fc0c5815..cd27300d 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -3,7 +3,7 @@ //! fixtures built in code with the parent module's helpers. use figcraft_doc::proto::Action; -use figcraft_doc::{HandleMirroring, StrokeCap, WindingRule}; +use figcraft_doc::{HandleMirroring, StrokeCap, StrokeJoin, WindingRule}; use super::super::network::{self, Network, Region, Segment, Vertex}; use super::*; @@ -19,7 +19,7 @@ fn vtx(style: u32, x: f64, y: f64) -> Vertex { /// A square with one curved side and a rounded, mirrored corner, filled even-odd. fn square_blob() -> Vec { network::encode(&Network { - vertices: vec![vtx(0, 0.0, 0.0), vtx(1, 50.0, 0.0), vtx(0, 50.0, 50.0), vtx(0, 0.0, 50.0)], + vertices: vec![vtx(0, 0.0, 0.0), vtx(1, 50.0, 0.0), vtx(2, 50.0, 50.0), vtx(3, 0.0, 50.0)], segments: vec![ seg(0, 1, (10.0, 0.0), (-10.0, 0.0)), seg(1, 2, (0.0, 0.0), (0.0, 0.0)), @@ -39,6 +39,15 @@ fn arrow_blob() -> Vec { }) } +/// An open path with one missing style ID and one unsupported point join. +fn join_fallback_blob() -> Vec { + network::encode(&Network { + vertices: vec![vtx(4, 0.0, 0.0), vtx(5, 50.0, 0.0), vtx(0, 100.0, 0.0)], + segments: vec![seg(0, 1, (0.0, 0.0), (0.0, 0.0)), seg(1, 2, (0.0, 0.0), (0.0, 0.0))], + regions: vec![], + }) +} + fn vector_data(blob: u64, nx: f64, ny: f64, styles: Vec>) -> Kv<'static> { o(vec![("vectorNetworkBlob", u(blob)), ("normalizedSize", xy(nx, ny)), ("styleOverrideTable", arr(styles))]) } @@ -121,13 +130,24 @@ fn tail_message() -> Kv<'static> { ("transform", at(700.0, 0.0)), ("fillPaints", arr(vec![solid(1.0, 0.0, 0.0)])), ("fillGeometry", outline(0)), + ("strokeJoin", e("MITER")), + ("dashPattern", arr(vec![f(4.0), f(2.0)])), ( "vectorData", vector_data( 1, 100.0, 50.0, - vec![o(vec![("styleID", u(1)), ("cornerRadius", f(4.0)), ("handleMirroring", e("ANGLE_AND_LENGTH"))])], + vec![ + o(vec![ + ("styleID", u(1)), + ("cornerRadius", f(4.0)), + ("handleMirroring", e("ANGLE_AND_LENGTH")), + ("strokeJoin", e("BEVEL")), + ]), + o(vec![("styleID", u(2)), ("strokeJoin", e("MITER"))]), + o(vec![("styleID", u(3)), ("strokeJoin", e("ROUND"))]), + ], ), ), ], @@ -146,6 +166,19 @@ fn tail_message() -> Kv<'static> { ("vectorData", vector_data(2, 100.0, 0.0, vec![o(vec![("styleID", u(2)), ("strokeCap", e("ARROW_LINES"))])])), ], ), + node( + 42, + Some((1, "#")), + "VECTOR", + "Join Fallbacks", + vec![ + ("size", xy(100.0, 10.0)), + ("transform", at(0.0, 330.0)), + stroke(), + ("strokeJoin", e("MITER")), + ("vectorData", vector_data(4, 100.0, 10.0, vec![o(vec![("styleID", u(5)), ("strokeJoin", u(999))])])), + ], + ), node( 32, Some((1, "#")), @@ -223,7 +256,7 @@ fn tail_message() -> Kv<'static> { ]; let mut broken = square_blob(); broken.truncate(30); - let blobs = [triangle_blob(), square_blob(), arrow_blob(), broken]; + let blobs = [triangle_blob(), square_blob(), arrow_blob(), broken, join_fallback_blob()]; o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(blobs.into_iter().map(|b| o(vec![("bytes", Kv::Bytes(b))])).collect()))]) } @@ -247,6 +280,11 @@ fn vectors_import_as_editable_networks() { assert_eq!(pts, [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]); assert_eq!((wave.vertices[1].radius, wave.vertices[1].mirror), (4.0, Some(HandleMirroring::AngleAndLength))); assert_eq!(wave.vertices[0].mirror, None); + assert_eq!( + wave.vertices.iter().map(|v| v.join).collect::>(), + [None, Some(StrokeJoin::Bevel), Some(StrokeJoin::Miter), Some(StrokeJoin::Round)] + ); + assert_eq!(named(&doc, "Wave").stroke.join, StrokeJoin::Miter); assert_eq!((wave.segments[0].ta, wave.segments[0].tb), ((20.0, 0.0), (-20.0, 0.0))); assert_eq!(wave.regions.len(), 1); assert_eq!((wave.regions[0].loops.clone(), wave.regions[0].winding), (vec![vec![0, 1, 2, 3]], WindingRule::EvenOdd)); @@ -260,6 +298,12 @@ fn vectors_import_as_editable_networks() { // A damaged network falls back to the outline. assert_eq!(network_of(&doc, "Broken").regions.len(), 1); assert!(warnings.iter().any(|w| w.contains("damaged vector networks import as outlines")), "{warnings:?}"); + let fallback = network_of(&doc, "Join Fallbacks"); + assert!(fallback.vertices.iter().all(|v| v.join.is_none())); + assert_eq!(named(&doc, "Join Fallbacks").stroke.join, StrokeJoin::Miter); + assert!(warnings.iter().any(|w| w.contains("unresolved style IDs lost their point styles")), "{warnings:?}"); + assert!(warnings.iter().any(|w| w.contains("unknown stroke joins use the layer join")), "{warnings:?}"); + assert!(warnings.iter().any(|w| w.contains("dashed or variable-width strokes render with the layer join")), "{warnings:?}"); } #[test] diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 19c4fc15..8d17cb4a 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -72,7 +72,7 @@ message Event InteractionType interactionType message Action GUID transitionNodeID, TransitionType transitionType, float transitionDuration, EasingType easingType, ConnectionType connectionType, NavigationType navigationType, VariableTarget targetVariable, VariableData targetVariableData, VariableSetID targetVariableSetID, GUID targetVariableModeID, ConditionalActions[] conditionalActions message Interaction Event event, Action[] actions message StartingPoint string name, string position -message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version, float[] dashPattern message Blob byte[] bytes message Message NodeChange[] nodeChanges, Blob[] blobs "; diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 553fce04..9d92892a 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -525,6 +525,50 @@ mod tests { assert!(arrowhead(&BezPath::new(), StrokeCap::LineArrow, 1.0, true).is_none()); } + #[test] + fn equal_point_joins_keep_the_layer_stroke_and_differing_ones_apply() { + let mut path = BezPath::new(); + path.move_to((0.0, 0.0)); + path.line_to((10.0, 10.0)); + path.line_to((20.0, 0.0)); + let net = VectorNetwork { + vertices: vec![ + figcraft_doc::Vertex { cap: Some(StrokeCap::Square), ..figcraft_doc::Vertex::new(0.0, 0.0) }, + figcraft_doc::Vertex { x: 10.0, y: 10.0, radius: 2.0, join: Some(StrokeJoin::Miter), ..figcraft_doc::Vertex::new(10.0, 10.0) }, + figcraft_doc::Vertex { cap: Some(StrokeCap::Square), ..figcraft_doc::Vertex::new(20.0, 0.0) }, + ], + segments: vec![figcraft_doc::Segment::line(0, 1), figcraft_doc::Segment::line(1, 2)], + ..Default::default() + }; + let props = StrokeProps { weight: 4.0, ..Default::default() }; + let inherited = stroke_outline_of(&path, &props, false, Some(&net)); + let mut without_join = net.clone(); + without_join.vertices[1].join = None; + let baseline = stroke_outline_of(&path, &props, false, Some(&without_join)); + assert_eq!(inherited, baseline); + + let different_layer = StrokeProps { join: StrokeJoin::Bevel, ..props }; + let explicit = stroke_outline_of(&path, &different_layer, false, Some(&net)); + let layer_only = stroke_outline_of(&path, &different_layer, false, Some(&without_join)); + assert_ne!(explicit, layer_only); + } + + #[test] + fn equal_joins_do_not_break_closed_stroke_runs() { + let path = sq(0.0, 0.0, 20.0); + let mut net = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(20.0, 0.0), Point::new(20.0, 20.0), Point::new(0.0, 20.0)], true); + for vertex in &mut net.vertices { + vertex.join = Some(StrokeJoin::Round); + } + let props = StrokeProps { join: StrokeJoin::Round, weight: 4.0, ..Default::default() }; + let styled = stroke_outline_of(&path, &props, true, Some(&net)); + for vertex in &mut net.vertices { + vertex.join = None; + } + let inherited = stroke_outline_of(&path, &props, true, Some(&net)); + assert_eq!(styled, inherited); + } + #[test] fn dashes() { let mut line = BezPath::new(); diff --git a/crates/pathops/src/points.rs b/crates/pathops/src/points.rs index d432da05..9c161b7c 100644 --- a/crates/pathops/src/points.rs +++ b/crates/pathops/src/points.rs @@ -18,11 +18,18 @@ pub(crate) struct Styled { impl Styled { /// `None` when no point has a style of its own, or the stroke is dashed, variable or a brush. /// shortcut: dashed, variable-width and brush strokes keep the layer's caps and joins at every point; stroke them run by run too if asked for. + /// Joins equal to the layer do not require styled runs; stored overrides remain unchanged. pub(crate) fn new(net: &VectorNetwork, props: &StrokeProps) -> Option { - if !net.has_point_styles() || !props.dashes.is_empty() || props.profile().is_some() || props.brush.is_some() { + let styled = net.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some_and(|join| join != props.join)); + if !styled || !props.dashes.is_empty() || props.profile().is_some() || props.brush.is_some() { return None; } - let net = net.rounded().unwrap_or_else(|| net.clone()); + let mut net = net.rounded().unwrap_or_else(|| net.clone()); + for vertex in &mut net.vertices { + if vertex.join == Some(props.join) { + vertex.join = None; + } + } let runs = net.stroke_runs(); let mut degrees = vec![0usize; net.vertices.len()]; for s in &net.segments { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d8e51711..dcd75691 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -17,7 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, per-point stroke joins (strokes that are dashed or variable-width still draw with the layer's join, with a warning), end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout, constraints, grids, components, sets, instances with overrides, component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | From c56a65f58bbcc63f7eadcd4ffb42157dfb1afaab Mon Sep 17 00:00:00 2001 From: vxMacDev <77440838+vxMacDev@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:42:59 -0500 Subject: [PATCH 004/118] fix(format): keep vertical trim on .fig import (#466) Figma's schema has leadingTrim on NodeChange (NONE, CAP_HEIGHT), and the REST importer already reads style.leadingTrim, but the .fig mapping never copied it, so text set to "Cap height to baseline" imported untrimmed in its trimmed box. text_style now copies it with the other text fields, from the layer or its text style. --- crates/format/src/fig/map.rs | 1 + crates/format/src/fig/tests.rs | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 66a00129..343e916d 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1729,6 +1729,7 @@ fn text_style(v: &View, size: Option) -> Obj { copy_num(&mut s, v, "paragraphIndent", "paragraphIndent"); copy_str(&mut s, v, "textCase", "textCase"); copy_str(&mut s, v, "textDecoration", "textDecoration"); + copy_str(&mut s, v, "leadingTrim", "leadingTrim"); let font_size = v.num("fontSize").or(size).unwrap_or(12.0); if let Some(ls) = v.get("letterSpacing") { let val = num(ls, "value").unwrap_or(0.0); diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 19c4fc15..d928868b 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -33,6 +33,7 @@ enum NavigationType NAVIGATE OVERLAY enum TransitionType INSTANT_TRANSITION DISSOLVE SMART_ANIMATE enum EasingType IN_CUBIC OUT_CUBIC enum ConstraintType MIN CENTER MAX STRETCH SCALE +enum LeadingTrim NONE CAP_HEIGHT struct GUID uint sessionID, uint localID struct Color float r, float g, float b, float a struct Vector float x, float y @@ -72,7 +73,7 @@ message Event InteractionType interactionType message Action GUID transitionNodeID, TransitionType transitionType, float transitionDuration, EasingType easingType, ConnectionType connectionType, NavigationType navigationType, VariableTarget targetVariable, VariableData targetVariableData, VariableSetID targetVariableSetID, GUID targetVariableModeID, ConditionalActions[] conditionalActions message Interaction Event event, Action[] actions message StartingPoint string name, string position -message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version, LeadingTrim leadingTrim message Blob byte[] bytes message Message NodeChange[] nodeChanges, Blob[] blobs "; @@ -283,6 +284,7 @@ fn message() -> Kv<'static> { ("fillPaints", arr(vec![solid(0.0, 0.0, 0.0)])), ("fontSize", f(16.0)), ("fontName", font("Regular")), + ("leadingTrim", e("CAP_HEIGHT")), ("textData", text), ], ), @@ -477,6 +479,7 @@ fn imports_the_fixture() { let NodeKind::Text(t) = &named(&doc, "Title").kind else { panic!("not text") }; assert_eq!(t.characters, "Hi there"); assert_eq!(t.style.font_size, 16.0); + assert_eq!(t.leading_trim, figcraft_doc::LeadingTrim::CapHeight, "vertical trim (#466)"); assert_eq!(t.runs.len(), 1); assert_eq!((t.runs[0].start, t.runs[0].end, t.runs[0].style.font_style.as_str()), (3, 6, "Bold")); // The group's children keep their place on the page (group at 100, 100). From d4394e87ccbe2e30a7b3b78cd9d3b154fa2de44e Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Thu, 15 Oct 2026 23:42:06 +0200 Subject: [PATCH 005/118] M1: save a copy as .fig Save as now offers .figcraft and .fig as separate file types. A .fig destination, chosen in the dialog or passed to file.saveAs and file.save, writes the existing experimental .fig export of the document that is shown and reports how much was left out. The file's own path, unsaved changes, versions, branches and undo history are not touched, and a shown earlier version stays shown while the copy is written or a Save as choice is pending. Plain Save no longer writes over a file whose saved path ends in .fig; earlier builds could write native data under that name. It asks for a destination instead. --- apps/figcraft/src/services.rs | 36 ++- crates/engine/src/cmd/file.rs | 35 ++- crates/engine/src/lib.rs | 6 +- crates/engine/src/save_format_tests.rs | 366 +++++++++++++++++++++++++ crates/mcp/src/tools.rs | 4 +- crates/ui-egui/locales/de.toml | 2 + crates/ui-egui/locales/en.toml | 2 + crates/ui-egui/locales/es.toml | 2 + crates/ui-egui/src/dialogs.rs | 34 ++- crates/ui-egui/src/lib.rs | 41 ++- crates/ui-egui/src/save_as_tests.rs | 320 +++++++++++++++++++++ docs/file-format-parity.md | 7 + 12 files changed, 835 insertions(+), 20 deletions(-) create mode 100644 crates/engine/src/save_format_tests.rs create mode 100644 crates/ui-egui/src/save_as_tests.rs diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index bd8a4204..eb585551 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -223,12 +223,25 @@ fn picked(pick: impl Future> + 'static) -> Dial Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) }) } +/// Native Save as filters; other callers keep their existing combined filter. +fn save_filters<'a>(exts: &'a [&'a str]) -> Vec<(&'static str, &'a [&'a str])> { + if exts == ["figcraft", "fig"] { + vec![("FigCraft (.figcraft)", &exts[..1]), (figcraft_ui_egui::i18n::tr(".fig (experimental)"), &exts[1..])] + } else { + vec![("File", exts)] + } +} + impl Services for Desktop { fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture { picked(dialog(title).add_filter("Supported files", exts).pick_file()) } fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture { - picked(dialog(title).set_file_name(name).add_filter("File", exts).save_file()) + let mut dialog = dialog(title).set_file_name(name); + for (label, extensions) in save_filters(exts) { + dialog = dialog.add_filter(label, extensions); + } + picked(dialog.save_file()) } fn pick_folder(&mut self, title: &str) -> DialogFuture { picked(dialog(title).pick_folder()) @@ -297,3 +310,24 @@ pub fn window_icon() -> Option { let (w, h) = img.dimensions(); Some(egui::IconData { rgba: img.into_raw(), width: w, height: h }) } + +#[cfg(test)] +mod save_filter_tests { + use super::save_filters; + + #[test] + fn native_and_fig_save_filters_are_separate_with_native_first() { + let extensions = ["figcraft", "fig"]; + assert_eq!( + save_filters(&extensions), + vec![("FigCraft (.figcraft)", &extensions[..1]), (figcraft_ui_egui::i18n::tr(".fig (experimental)"), &extensions[1..])] + ); + } + + #[test] + fn other_save_filters_keep_one_combined_group() { + for extensions in [&["figcraft"][..], &["fig"][..], &["png"][..], &["png", "jpg", "webp"][..], &[][..]] { + assert_eq!(save_filters(extensions), vec![("File", extensions)]); + } + } +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 54348372..9134bc71 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -41,8 +41,20 @@ pub fn specs() -> Vec { false, open_recent ), - spec!("file.save", "Save", "{path?} (programmatic saves need a path the first time)", false, save), - spec!("file.saveAs", "Save as", "{path}", false, save_as), + spec!( + "file.save", + "Save", + "{path?} (programmatic saves need a path the first time); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", + false, + save + ), + spec!( + "file.saveAs", + "Save as", + "{path} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", + false, + save_as + ), spec!( "file.export", "Export", @@ -775,8 +787,20 @@ fn color_profile(s: &mut Session, p: &Value) -> Result { Ok(json!({"profile": profile.id(), "changed": changed})) } -/// Save the file of the active tab (the main file for a branch tab, with its branches). +/// Native saves act on the current document; a `.fig` copy exports the version shown, and a +/// pending Save As choice must leave it shown until its destination is known. +pub(crate) fn save_ends_preview(s: &Session, cmd: &str, p: &Value) -> bool { + let path = + string(p, "path").or_else(|| if cmd == "file.save" { s.docs.get(crate::branches::file_tab(s)).and_then(|ds| ds.path.clone()) } else { None }); + path.is_some_and(|path| ext(&path) != "fig") +} + +/// Save the file of the active tab (the main file for a branch tab, with its branches), or export +/// the active document as an experimental `.fig` copy without changing its native save state. fn save_to(s: &mut Session, path: String) -> Result { + if ext(&path) == "fig" { + return export_fig(s, &json!({"path": path})); + } let i = crate::branches::file_tab(s); let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; let bytes = file_bytes(ds)?; @@ -795,6 +819,11 @@ fn save(s: &mut Session, p: &Value) -> Result { return save_to(s, path); } match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() { + // Earlier builds wrote native data under a .fig name: never overwrite that file with an export. + Some(path) if ext(&path) == "fig" => { + s.request_ui("dialog.saveAs", json!({})); + Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into())) + } Some(path) => save_to(s, path), None => { s.request_ui("dialog.saveAs", json!({})); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..cba3577e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -390,7 +390,8 @@ pub struct VersionPreview { /// Why edits are refused while a version is shown. pub const PREVIEW_READ_ONLY: &str = "you're viewing an earlier version: restore it or go back to the current version to edit"; -/// Commands that first end a version preview: they act on the document itself. +/// Commands that first end a version preview: they act on the document itself. Saving does so +/// only with a native destination; export copies and pending format choices keep the preview. const ENDS_PREVIEW: &[&str] = &["file.save", "file.saveAs", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"]; static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -870,6 +871,7 @@ impl Session { // Every other command acts on the document itself, not on a live text preview. textpreview::end(self); if ENDS_PREVIEW.contains(&id) + && (!matches!(id, "file.save" | "file.saveAs") || cmd::file::save_ends_preview(self, id, params)) && let Ok(ds) = self.ds_mut() { ds.end_preview(); @@ -1642,6 +1644,8 @@ mod rotated_select_tests; #[cfg(test)] mod rotation_origin_tests; #[cfg(test)] +mod save_format_tests; +#[cfg(test)] mod scale_tool_tests; #[cfg(test)] mod section_contents_tests; diff --git a/crates/engine/src/save_format_tests.rs b/crates/engine/src/save_format_tests.rs new file mode 100644 index 00000000..4473ef7c --- /dev/null +++ b/crates/engine/src/save_format_tests.rs @@ -0,0 +1,366 @@ +//! Save As can write an experimental `.fig` copy without replacing native save state. + +use std::path::PathBuf; +use std::sync::Arc; + +use figcraft_format::Version; +use serde_json::{Value, json}; + +use crate::{DocState, EngineError, Session}; + +struct SaveFiles { + dir: PathBuf, + files: Vec, +} + +impl SaveFiles { + fn new() -> Self { + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("figcraft-save-formats-{}-{stamp}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + Self { dir, files: Vec::new() } + } + + fn path(&mut self, name: &str) -> PathBuf { + let path = self.dir.join(name); + self.files.push(path.clone()); + path + } +} + +impl Drop for SaveFiles { + fn drop(&mut self) { + for path in &self.files { + let _ = std::fs::remove_file(path); + let _ = std::fs::remove_dir(path); + } + let _ = std::fs::remove_dir(&self.dir); + } +} + +fn rect(s: &mut Session, name: &str) { + s.execute("shape.rectangle", &json!({"name": name, "x": 10, "y": 20, "width": 30, "height": 40})).unwrap(); +} + +fn assert_versions_unchanged(after: &[Version], before: &[Version]) { + assert_eq!(after.len(), before.len()); + for (after, before) in after.iter().zip(before) { + assert_eq!( + (&after.id, &after.name, &after.description, after.time, after.auto), + (&before.id, &before.name, &before.description, before.time, before.auto) + ); + assert_eq!(*after.document().unwrap(), *before.document().unwrap()); + } +} + +fn assert_save_state_unchanged(s: &Session, before: &DocState, recent: &[String]) { + let after = s.ds().unwrap(); + assert_eq!(after.path, before.path); + assert_eq!(after.is_dirty(), before.is_dirty()); + assert_eq!(after.versions_changed, before.versions_changed); + assert!(Arc::ptr_eq(&after.doc, &before.doc)); + assert!(Arc::ptr_eq(&after.saved_doc, &before.saved_doc)); + assert_eq!( + after.preview.as_ref().map(|p| (&p.id, &p.name, p.time, &p.selection, p.page)), + before.preview.as_ref().map(|p| (&p.id, &p.name, p.time, &p.selection, p.page)) + ); + if let (Some(after), Some(before)) = (&after.preview, &before.preview) { + assert!(Arc::ptr_eq(&after.doc, &before.doc)); + } + assert_eq!((&after.selection, after.page), (&before.selection, before.page)); + assert_versions_unchanged(&after.versions, &before.versions); + assert_eq!(after.branches.len(), before.branches.len()); + for (after, before) in after.branches.iter().zip(&before.branches) { + assert_eq!( + (&after.id, &after.name, after.time, after.client, after.state), + (&before.id, &before.name, before.time, before.client, before.state) + ); + assert_eq!(*after.base, *before.base); + assert_eq!(*after.head, *before.head); + assert_versions_unchanged(&after.versions, &before.versions); + } + for (after, before) in [(&after.history.undo, &before.history.undo), (&after.history.redo, &before.history.redo)] { + assert_eq!(after.len(), before.len()); + for (after, before) in after.iter().zip(before) { + assert_eq!((&after.label, &after.selection, after.page), (&before.label, &before.selection, before.page)); + assert!(Arc::ptr_eq(&after.doc, &before.doc)); + } + } + assert_eq!(s.prefs.recent_files, recent); +} + +fn read_fig(path: &std::path::Path, result: &Value) -> figcraft_doc::Document { + let bytes = std::fs::read(path).unwrap(); + assert_eq!(result["format"], "fig"); + assert_eq!(result["bytes"], bytes.len()); + assert!(result["warnings"].is_array()); + assert!(figcraft_format::load_bytes(&bytes).is_err(), "a .fig copy must not contain native FigCraft data"); + figcraft_format::fig::import(&bytes, "Copy").unwrap().0 +} + +#[test] +fn fig_save_as_keeps_native_path_dirty_state_and_history() { + let mut files = SaveFiles::new(); + let native = files.path("design.figcraft"); + let copy = files.path("copy.fig"); + let mut s = Session::new(); + rect(&mut s, "Saved rectangle"); + s.execute("file.saveAs", &json!({"path": native.to_string_lossy()})).unwrap(); + let saved = std::fs::read(&native).unwrap(); + rect(&mut s, "New rectangle"); + s.execute("comment.add", &json!({"x": 0, "y": 0, "text": "Copy warning"})).unwrap(); + s.execute("file.saveVersion", &json!({"name": "Before copy"})).unwrap(); + rect(&mut s, "Undone rectangle"); + s.undo().unwrap(); + let before = s.ds().unwrap().clone(); + let recent = s.prefs.recent_files.clone(); + assert!(before.is_dirty()); + assert!(!before.history.redo.is_empty()); + + let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap(); + let imported = read_fig(©, &result); + assert!(imported.nodes().any(|n| n.name == "New rectangle")); + assert!(!imported.nodes().any(|n| n.name == "Undone rectangle")); + assert!(result["warnings"].as_array().unwrap().iter().any(|w| w == "comments were left out")); + assert_save_state_unchanged(&s, &before, &recent); + assert_eq!(std::fs::read(&native).unwrap(), saved); + + s.execute("file.save", &json!({})).unwrap(); + assert!(!s.ds().unwrap().is_dirty()); + assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str()); + let persisted = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap(); + assert!(persisted.doc.nodes().any(|n| n.name == "New rectangle")); + assert_eq!(persisted.versions[0].name, "Before copy"); + assert_eq!(std::fs::read(©).unwrap().len(), result["bytes"].as_u64().unwrap() as usize); +} + +#[test] +fn unsaved_fig_copy_remains_unsaved() { + let mut files = SaveFiles::new(); + let copy = files.path("unsaved.fig"); + let mut s = Session::new(); + rect(&mut s, "Unsaved rectangle"); + let before = s.ds().unwrap().clone(); + let recent = s.prefs.recent_files.clone(); + let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap(); + assert!(read_fig(©, &result).nodes().any(|n| n.name == "Unsaved rectangle")); + assert_save_state_unchanged(&s, &before, &recent); + assert!(matches!(s.execute("file.save", &json!({})), Err(EngineError::Disabled(..)))); + assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs"); +} + +#[test] +fn empty_document_fig_copy_keeps_clean_state() { + let mut files = SaveFiles::new(); + let copy = files.path("empty.fig"); + let mut s = Session::new(); + let before = s.ds().unwrap().clone(); + assert!(!before.is_dirty()); + let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap(); + let imported = read_fig(©, &result); + assert_eq!(imported.pages.len(), 1); + assert_eq!(imported.node_count(), 1); + assert_save_state_unchanged(&s, &before, &[]); +} + +#[test] +fn fig_save_commands_accept_uppercase_extension() { + let mut files = SaveFiles::new(); + for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() { + let copy = files.path(&format!("copy-{index}.FIG")); + let mut s = Session::new(); + rect(&mut s, command); + let before = s.ds().unwrap().clone(); + let result = s.execute(command, &json!({"path": copy.to_string_lossy()})).unwrap(); + assert!(read_fig(©, &result).nodes().any(|n| n.name == command)); + assert_save_state_unchanged(&s, &before, &[]); + } +} + +#[test] +fn fig_copy_from_branch_exports_active_document_without_saving_main() { + let mut files = SaveFiles::new(); + let native = files.path("main.figcraft"); + let copy = files.path("branch.fig"); + let mut s = Session::new(); + rect(&mut s, "Main rectangle"); + s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap(); + let saved = std::fs::read(&native).unwrap(); + s.execute("branch.create", &json!({"name": "Explore"})).unwrap(); + rect(&mut s, "Branch rectangle"); + s.execute("file.saveVersion", &json!({"name": "Branch version"})).unwrap(); + // Branch edits normally sync to main before commands; capture that state before exporting. + s.execute("branch.list", &json!({})).unwrap(); + let branch = s.ds().unwrap().clone(); + let main = s.docs[0].clone(); + let recent = s.prefs.recent_files.clone(); + + let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap(); + assert!(read_fig(©, &result).nodes().any(|n| n.name == "Branch rectangle")); + assert_save_state_unchanged(&s, &branch, &recent); + assert_eq!(s.docs[0].path, main.path); + assert_eq!(s.docs[0].is_dirty(), main.is_dirty()); + assert!(Arc::ptr_eq(&s.docs[0].saved_doc, &main.saved_doc)); + assert!(Arc::ptr_eq(&s.docs[0].doc, &main.doc)); + assert_eq!(std::fs::read(&native).unwrap(), saved); + + s.execute("file.save", &json!({})).unwrap(); + let persisted = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap(); + assert!(!persisted.doc.nodes().any(|n| n.name == "Branch rectangle")); + assert!(persisted.branches[0].head.nodes().any(|n| n.name == "Branch rectangle")); + assert_eq!(persisted.branches[0].versions[0].name, "Branch version"); + assert!(!s.ds().unwrap().is_dirty()); + assert!(!s.docs[0].is_dirty()); +} + +#[test] +fn fig_copy_write_failure_keeps_save_state() { + let mut files = SaveFiles::new(); + let blocked = files.path("blocked.fig"); + std::fs::create_dir(&blocked).unwrap(); + let missing = files.path("missing/copy.fig"); + let mut s = Session::new(); + rect(&mut s, "Kept rectangle"); + s.execute("file.saveVersion", &json!({"name": "Kept version"})).unwrap(); + rect(&mut s, "Later rectangle"); + s.execute("version.preview", &json!({"index": 0})).unwrap(); + let before = s.ds().unwrap().clone(); + for command in ["file.save", "file.saveAs"] { + for path in [&blocked, &missing] { + assert!(s.execute(command, &json!({"path": path.to_string_lossy()})).is_err()); + assert_save_state_unchanged(&s, &before, &[]); + assert!(!PathBuf::from(format!("{}.tmp-figcraft", path.display())).exists()); + } + } +} + +#[test] +fn save_as_without_extension_stays_native() { + let mut files = SaveFiles::new(); + let requested = files.dir.join("native"); + let native = files.path("native.figcraft"); + let mut s = Session::new(); + rect(&mut s, "Native rectangle"); + let result = s.execute("file.saveAs", &json!({"path": requested.to_string_lossy()})).unwrap(); + assert_eq!(result["path"], native.to_string_lossy().as_ref()); + assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str()); + assert!(!s.ds().unwrap().is_dirty()); + assert!(!requested.exists()); + let loaded = figcraft_format::load_bytes(&std::fs::read(native).unwrap()).unwrap(); + assert!(loaded.0.nodes().any(|n| n.name == "Native rectangle")); +} + +#[test] +fn fig_copy_of_version_preview_keeps_visible_version_and_save_state() { + let mut files = SaveFiles::new(); + for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() { + let native = files.path(&format!("current-{index}.figcraft")); + let copy = files.path(&format!("version-{index}.FIG")); + let mut s = Session::new(); + rect(&mut s, "A"); + s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap(); + s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap(); + let saved = std::fs::read(&native).unwrap(); + rect(&mut s, "B"); + s.execute("version.preview", &json!({"index": 0})).unwrap(); + let before = s.ds().unwrap().clone(); + let recent = s.prefs.recent_files.clone(); + assert!(before.preview.is_some()); + assert!(before.is_dirty()); + + let result = s.execute(command, &json!({"path": copy.to_string_lossy()})).unwrap(); + let imported = read_fig(©, &result); + assert!(imported.nodes().any(|n| n.name == "A")); + assert!(!imported.nodes().any(|n| n.name == "B"), "the copy must export the version shown"); + assert_save_state_unchanged(&s, &before, &recent); + assert_eq!(std::fs::read(&native).unwrap(), saved); + s.execute("version.exitPreview", &json!({})).unwrap(); + assert!(s.doc().unwrap().nodes().any(|n| n.name == "B")); + } +} + +#[test] +fn pending_save_choice_keeps_version_preview() { + let mut files = SaveFiles::new(); + for (index, (command, native_path)) in [("file.saveAs", false), ("file.saveAs", true), ("file.save", false)].into_iter().enumerate() { + let mut s = Session::new(); + rect(&mut s, "A"); + s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap(); + if native_path { + let native = files.path(&format!("saved-{index}.figcraft")); + s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap(); + } + rect(&mut s, "B"); + s.execute("version.preview", &json!({"index": 0})).unwrap(); + let before = s.ds().unwrap().clone(); + let recent = s.prefs.recent_files.clone(); + assert!(matches!(s.execute(command, &json!({})), Err(EngineError::Disabled(..)))); + assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs"); + assert_save_state_unchanged(&s, &before, &recent); + } +} + +#[test] +fn native_save_from_version_preview_writes_current_document() { + let mut files = SaveFiles::new(); + for (index, (command, explicit)) in [("file.save", false), ("file.save", true), ("file.saveAs", true)].into_iter().enumerate() { + let native = files.path(&format!("native-version-{index}.figcraft")); + let mut s = Session::new(); + rect(&mut s, "A"); + s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap(); + s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap(); + rect(&mut s, "B"); + s.execute("version.preview", &json!({"index": 0})).unwrap(); + let undo = s.ds().unwrap().history.undo.len(); + let params = if explicit { json!({"path": native.to_string_lossy()}) } else { json!({}) }; + s.execute(command, ¶ms).unwrap(); + assert!(s.ds().unwrap().preview.is_none()); + assert!(!s.ds().unwrap().is_dirty()); + assert_eq!(s.ds().unwrap().history.undo.len(), undo); + assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str()); + let loaded = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap(); + assert!(loaded.doc.nodes().any(|n| n.name == "A")); + assert!(loaded.doc.nodes().any(|n| n.name == "B")); + assert!(!loaded.versions[0].document().unwrap().nodes().any(|n| n.name == "B")); + } +} + +#[test] +fn implicit_save_protects_legacy_fig_destination() { + let mut files = SaveFiles::new(); + for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() { + let legacy = files.path(&format!("legacy-{index}.FIG")); + let native = files.path(&format!("corrected-{index}.figcraft")); + let mut s = Session::new(); + rect(&mut s, "A"); + s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap(); + // Earlier builds could write native data under .fig; keep those bytes safe on plain Save. + let sentinel = crate::cmd::file::file_bytes(s.ds().unwrap()).unwrap(); + std::fs::write(&legacy, &sentinel).unwrap(); + s.ds_mut().unwrap().path = Some(legacy.to_string_lossy().into_owned()); + s.ds_mut().unwrap().mark_saved(); + rect(&mut s, "B"); + s.execute("version.preview", &json!({"index": 0})).unwrap(); + let before = s.ds().unwrap().clone(); + let recent = s.prefs.recent_files.clone(); + + assert!(matches!(s.execute("file.save", &json!({})), Err(EngineError::Disabled(..)))); + assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs"); + assert_save_state_unchanged(&s, &before, &recent); + assert_eq!(std::fs::read(&legacy).unwrap(), sentinel); + + s.execute(command, &json!({"path": native.to_string_lossy()})).unwrap(); + assert!(s.ds().unwrap().preview.is_none()); + assert!(!s.ds().unwrap().is_dirty()); + assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str()); + let loaded = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap(); + assert!(loaded.doc.nodes().any(|n| n.name == "A")); + assert!(loaded.doc.nodes().any(|n| n.name == "B")); + assert_eq!(loaded.versions[0].name, "A only"); + rect(&mut s, "C"); + s.execute("file.save", &json!({})).unwrap(); + let loaded = figcraft_format::load_bytes(&std::fs::read(&native).unwrap()).unwrap(); + assert!(loaded.0.nodes().any(|n| n.name == "C")); + assert_eq!(std::fs::read(&legacy).unwrap(), sentinel); + } +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 49fd272f..511d329e 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -250,8 +250,8 @@ pub fn tool_definitions() -> Vec { tool( "save_file", "Save file", - "Save the active document as a .figcraft file. `path` is required the first time (an untitled document has no path); later saves reuse it.", - obj(json!({"path": string("Output .figcraft path")}), &[]), + "Save the active document as a .figcraft file. `path` is required the first time (an untitled document has no path); later saves reuse it. A `path` ending in .fig writes an experimental .fig export copy instead and leaves the document's own path and unsaved changes alone.", + obj(json!({"path": string("Output .figcraft path, or a .fig path for an experimental export copy")}), &[]), false, ), tool("undo", "Undo", "Undo the last change (one command, batch or gesture). Returns {undone: bool}.", empty(), false), diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index cd1e3482..1ecee425 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -17,6 +17,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} Commits, +{added} / −{deleted} Zeilen (Δ {delta}), +{bin_added} / −{bin_deleted} Binärdateien, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Eine Sammlung dieser Datei mit diesen Modi und Variablen: Überschreiben Sie ihre Werte je Modus und fügen Sie eigene Variablen hinzu" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Eine .figcraft-Datei mit diesem Namen existiert bereits. Wählen Sie ihren vollständigen Namen, um das Ersetzen zu bestätigen." "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Eine einzelne Ebene richtet sich an ihrem Rahmen aus: Wählen Sie weitere Ebenen oder eine Ebene in einem Rahmen" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Eine Feder läuft, bis sie zur Ruhe kommt: Masse, Steifigkeit und Dämpfung bestimmen die Dauer" "About" = "Über" @@ -1347,6 +1348,7 @@ status = "draft — needs native review" "Use as mask" = "Als Maske verwenden" "Use auto layout" = "Auto-Layout verwenden" "Use number keys for opacity" = "Zifferntasten für Deckkraft verwenden" +"Use the .figcraft or .fig extension." = "Verwenden Sie die Dateiendung .figcraft oder .fig." "Use the layer's fill" = "Füllung der Ebene verwenden" "Used in {files} · {instances}" = "Verwendet in {files} · {instances}" "Username" = "Benutzername" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 3c127fc6..fbae012c 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -16,6 +16,7 @@ name = "English" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "A collection of this file with these modes and variables: override their values per mode and add variables of its own" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "A .figcraft file with this name already exists. Choose its full name to confirm replacement." "A layer on its own aligns to its frame: select more layers, or one in a frame" = "A layer on its own aligns to its frame: select more layers, or one in a frame" "A responsive product page: hero, features, figures, a quote and a footer" = "A responsive product page: hero, features, figures, a quote and a footer" "A spring plays until it settles: its mass, stiffness and damping set how long" = "A spring plays until it settles: its mass, stiffness and damping set how long" @@ -1413,6 +1414,7 @@ name = "English" "Use as mask" = "Use as mask" "Use auto layout" = "Use auto layout" "Use number keys for opacity" = "Use number keys for opacity" +"Use the .figcraft or .fig extension." = "Use the .figcraft or .fig extension." "Use the layer's fill" = "Use the layer's fill" "Used in {files} · {instances}" = "Used in {files} · {instances}" "Username" = "Username" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 901283f1..df66fdc5 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -9,6 +9,7 @@ status = "draft — needs native review" [ui] "Couldn't download the variables" = "No se pudieron descargar las variables" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Ya existe un archivo .figcraft con este nombre. Elige su nombre completo para confirmar que deseas reemplazarlo." "% of all commits" = "% del total de commits" "(no longer published)" = "(ya no publicado)" @@ -1414,6 +1415,7 @@ status = "draft — needs native review" "Use as mask" = "Usar como máscara" "Use auto layout" = "Usar diseño automático" "Use number keys for opacity" = "Usar teclas numéricas para la opacidad" +"Use the .figcraft or .fig extension." = "Usa la extensión .figcraft o .fig." "Use the layer's fill" = "Usar el relleno de la capa" "Used in {files} · {instances}" = "Usado en {files} · {instances}" "Username" = "Usuario" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 236f8e0d..172d59c9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1,11 +1,13 @@ //! Dialogs: About, Preferences, keyboard shortcuts, find & replace, rename layers, variables, //! export. +use std::path::Path; + use egui::{Align2, Color32, Context, CornerRadius, RichText, Sense, Stroke, vec2}; use figcraft_color::ColorProfile; use figcraft_doc::{ExportFormat, ExportSetting, ExportSize, NodeId}; use figcraft_engine::cmd::file::{export_jobs, exportable_layers}; -use serde_json::json; +use serde_json::{Value, json}; use crate::FigApp; use crate::export_panel::{export_layers, save_one, suggested_name}; @@ -505,17 +507,35 @@ fn color_profile_dialog(app: &mut FigApp, ctx: &Context) { } } +/// A `.fig` Save as result is an export; only native saves report that the document was saved. +pub(crate) fn file_saved(app: &mut FigApp, result: &Value) { + if result["format"] == "fig" { + fig_exported(app, result); + } else { + app.toast(trf("Saved {name}", &[("name", &result_file_name(result))])); + } +} + +fn result_file_name(result: &Value) -> String { + result["path"].as_str().and_then(|p| Path::new(p).file_name()).map(|f| f.to_string_lossy().into_owned()).unwrap_or_default() +} + +/// The `.fig` result stays an export: native save state is unchanged, and omissions are reported. +pub(crate) fn fig_exported(app: &mut FigApp, result: &Value) { + let file = result_file_name(result); + let left_out = result["warnings"].as_array().map_or(0, Vec::len); + app.toast(match left_out { + 0 => trf("Exported {name}", &[("name", &file)]), + n => trn("Exported {name}; {n} kinds of content were left out", n, &[("name", &file)]), + }); +} + /// Save the whole file as `.fig`; the toast says how much was left out. fn save_fig(app: &mut FigApp, name: &str) { let filename = format!("{name}.fig"); app.pick_save(tr("Export"), &filename, &["fig"], |app, p| { - let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(); if let Some(r) = app.run("file.export", json!({"format": "fig", "path": p.to_string_lossy()})) { - let left_out = r["warnings"].as_array().map_or(0, Vec::len); - app.toast(match left_out { - 0 => trf("Exported {name}", &[("name", &file)]), - n => trn("Exported {name}; {n} kinds of content were left out", n, &[("name", &file)]), - }); + fig_exported(app, &r); } }); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20a8a77c..cbd226a6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -162,6 +162,8 @@ mod proto_reorder_tests; #[cfg(test)] mod proto_responsive_tests; #[cfg(test)] +mod save_as_tests; +#[cfg(test)] mod select_all_tests; #[cfg(test)] mod shortcut_keys_tests; @@ -938,11 +940,31 @@ impl FigApp { } pub fn save_as_dialog(&mut self) { - let name = self.session.doc().map(|d| d.name.clone()).unwrap_or_else(|_| "Untitled".into()); - self.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft"], |app, p| { - let ps = p.to_string_lossy().to_string(); - if app.run("file.saveAs", json!({"path": ps})).is_some() { - app.toast(i18n::trf("Saved {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())])); + let Ok(name) = self.session.doc().map(|d| d.name.clone()) else { return }; + self.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft", "fig"], |app, mut p| { + if p.extension().is_none_or(|e| e.is_empty()) { + if !p.set_extension("figcraft") { + app.toast(i18n::tr("Use the .figcraft or .fig extension.")); + return; + } + // This name wasn't confirmed by the system dialog: don't replace an existing file. + match p.try_exists() { + Ok(false) => {} + Ok(true) => { + app.toast(i18n::tr("A .figcraft file with this name already exists. Choose its full name to confirm replacement.")); + return; + } + Err(e) => { + app.toast(e.to_string()); + return; + } + } + } else if !p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("figcraft") || e.eq_ignore_ascii_case("fig")) { + app.toast(i18n::tr("Use the .figcraft or .fig extension.")); + return; + } + if let Some(r) = app.run("file.saveAs", json!({"path": p.to_string_lossy()})) { + dialogs::file_saved(app, &r); } }); } @@ -950,7 +972,14 @@ impl FigApp { pub fn save(&mut self) { // A branch tab saves its main file. let tab = figcraft_engine::branches::file_tab(&self.session); - if self.session.docs.get(tab).is_some_and(|d| d.path.is_some()) { + // A path ending in .fig is never saved over silently: a .fig destination is an export copy. + let native_path = self + .session + .docs + .get(tab) + .and_then(|d| d.path.as_deref()) + .is_some_and(|path| !Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("fig"))); + if native_path { if self.run("file.save", json!({})).is_some() { self.toast(i18n::tr("Saved")); } diff --git a/crates/ui-egui/src/save_as_tests.rs b/crates/ui-egui/src/save_as_tests.rs new file mode 100644 index 00000000..8817ab1c --- /dev/null +++ b/crates/ui-egui/src/save_as_tests.rs @@ -0,0 +1,320 @@ +//! Native Save as accepts both file formats; .fig copies preserve native save state. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use figcraft_engine::Session; +use serde_json::json; + +use crate::dialogs::file_saved; +use crate::{DialogFuture, FigApp, Services}; + +type Picks = Arc)>>>; + +struct Picker { + path: Option, + picks: Picks, +} + +impl Services for Picker { + fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture { + self.picks.lock().unwrap().push((title.into(), name.into(), exts.iter().map(|e| (*e).into()).collect())); + Box::pin(std::future::ready(self.path.take())) + } +} + +struct Temp(PathBuf); + +impl Temp { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("figcraft-save-as-{}-{stamp}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + + fn path(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} + +impl Drop for Temp { + fn drop(&mut self) { + if let Ok(files) = std::fs::read_dir(&self.0) { + for file in files.flatten() { + let _ = std::fs::remove_file(file.path()); + } + } + let _ = std::fs::remove_dir(&self.0); + } +} + +fn session() -> Session { + let mut s = Session::new(); + s.doc_mut().unwrap().name = "Prototype".into(); + s.execute("shape.rectangle", &json!({"width": 100, "height": 80})).unwrap(); + s +} + +fn picker(path: Option) -> (Picker, Picks) { + let picks = Picks::default(); + (Picker { path, picks: picks.clone() }, picks) +} + +fn app(path: Option) -> (FigApp, Picks) { + let (picker, picks) = picker(path); + (FigApp::new(session(), Box::new(picker)), picks) +} + +fn toast(app: &FigApp) -> &str { + &app.ui.toast.as_ref().unwrap().0 +} + +fn expected_picker() -> Vec<(String, String, Vec)> { + vec![("Save as".into(), "Prototype.figcraft".into(), vec!["figcraft".into(), "fig".into()])] +} + +#[test] +fn save_as_menu_and_first_save_open_the_platform_picker_directly_with_both_extensions() { + for action in ["direct", "menu", "first"] { + let (mut app, picks) = app(None); + let before = app.session.doc().unwrap().clone(); + match action { + "direct" => app.save_as_dialog(), + "menu" => crate::menus::invoke(&mut app, "file.saveAs"), + "first" => app.save(), + _ => panic!("unknown save action {action}"), + } + app.poll_dialog(&egui::Context::default()); + assert_eq!(*picks.lock().unwrap(), expected_picker()); + assert_eq!(app.session.doc().unwrap(), &before); + assert!(app.session.ds().unwrap().path.is_none()); + assert!(app.session.ds().unwrap().is_dirty()); + assert!(app.ui.toast.is_none()); + } +} + +#[test] +fn a_pathless_engine_request_uses_the_same_platform_picker() { + let (mut app, picks) = app(None); + let before = app.session.doc().unwrap().clone(); + assert!(app.run("file.saveAs", json!({})).is_none(), "the engine requests the UI and refuses a pathless programmatic save"); + app.poll_dialog(&egui::Context::default()); + assert_eq!(*picks.lock().unwrap(), expected_picker()); + assert_eq!(app.session.doc().unwrap(), &before); + assert!(app.session.ds().unwrap().path.is_none()); + assert!(app.session.ds().unwrap().is_dirty()); +} + +#[test] +fn native_paths_and_an_extensionless_default_save_native_files_and_show_the_written_name() { + let temp = Temp::new(); + for (chosen, written) in [("native", "native.figcraft"), ("saved.figcraft", "saved.figcraft"), ("upper.FIGCRAFT", "upper.FIGCRAFT")] { + let (mut app, picks) = app(Some(temp.path(chosen))); + app.save_as_dialog(); + app.poll_dialog(&egui::Context::default()); + let path = temp.path(written); + assert_eq!(*picks.lock().unwrap(), expected_picker()); + assert!(path.is_file()); + assert_eq!(app.session.ds().unwrap().path.as_deref(), path.to_str()); + assert!(!app.session.ds().unwrap().is_dirty()); + assert_eq!(toast(&app), format!("Saved {written}")); + let mut reopened = Session::empty(); + reopened.execute("file.open", &json!({"path": path})).unwrap(); + assert_eq!(reopened.doc().unwrap().nodes().count(), app.session.doc().unwrap().nodes().count()); + assert_eq!(reopened.doc().unwrap().name, "Prototype"); + } +} + +#[test] +fn returned_fig_paths_export_real_copies_without_changing_native_save_state() { + let temp = Temp::new(); + for name in ["copy.fig", "upper.FIG"] { + let path = temp.path(name); + let (mut app, picks) = app(Some(path.clone())); + let native = temp.path("working.figcraft").to_string_lossy().into_owned(); + app.session.ds_mut().unwrap().path = Some(native.clone()); + let before = app.session.doc().unwrap().clone(); + app.save_as_dialog(); + app.poll_dialog(&egui::Context::default()); + assert_eq!(*picks.lock().unwrap(), expected_picker()); + assert!(path.is_file()); + assert_eq!(app.session.doc().unwrap(), &before); + assert_eq!(app.session.ds().unwrap().path.as_deref(), Some(native.as_str())); + assert!(app.session.ds().unwrap().is_dirty()); + assert_eq!(toast(&app), format!("Exported {name}")); + let mut reopened = Session::empty(); + reopened.execute("file.open", &json!({"path": path})).unwrap(); + assert_eq!(reopened.doc().unwrap().nodes().count(), before.nodes().count()); + } +} + +#[test] +fn a_fig_copy_reports_actual_engine_warnings_and_keeps_the_original_content() { + let temp = Temp::new(); + let (mut app, _) = app(Some(temp.path("comments.fig"))); + app.run("comment.add", json!({"x": 10, "y": 20, "text": "Synthetic comment"})).unwrap(); + let before = app.session.doc().unwrap().clone(); + app.save_as_dialog(); + app.poll_dialog(&egui::Context::default()); + assert!(temp.path("comments.fig").is_file()); + assert_eq!(toast(&app), "Exported comments.fig; 1 kind of content was left out"); + assert_eq!(app.session.doc().unwrap(), &before); + assert_eq!(app.session.doc().unwrap().comments.len(), 1); + assert!(app.session.ds().unwrap().path.is_none()); + assert!(app.session.ds().unwrap().is_dirty()); +} + +#[test] +fn cancelling_the_platform_dialog_preserves_document_selection_path_and_dirty_state() { + let (mut app, picks) = app(None); + app.session.ds_mut().unwrap().path = Some("working.figcraft".into()); + let before = app.session.doc().unwrap().clone(); + let selection = app.session.ds().unwrap().selection.clone(); + app.save_as_dialog(); + app.poll_dialog(&egui::Context::default()); + assert_eq!(*picks.lock().unwrap(), expected_picker()); + assert_eq!(app.session.doc().unwrap(), &before); + assert_eq!(app.session.ds().unwrap().selection, selection); + assert_eq!(app.session.ds().unwrap().path.as_deref(), Some("working.figcraft")); + assert!(app.session.ds().unwrap().is_dirty()); + assert!(app.ui.toast.is_none()); +} + +#[test] +fn unsupported_extensions_are_rejected_without_overwriting_the_chosen_file() { + let temp = Temp::new(); + for name in ["wrong.svg", "wrong.png"] { + let path = temp.path(name); + std::fs::write(&path, b"existing document").unwrap(); + let (mut app, _) = app(Some(path.clone())); + let before = app.session.doc().unwrap().clone(); + app.save_as_dialog(); + app.poll_dialog(&egui::Context::default()); + assert_eq!(std::fs::read(&path).unwrap(), b"existing document"); + assert_eq!(app.session.doc().unwrap(), &before); + assert!(app.session.ds().unwrap().path.is_none()); + assert!(app.session.ds().unwrap().is_dirty()); + assert_eq!(toast(&app), "Use the .figcraft or .fig extension."); + } +} + +#[test] +fn adding_the_default_extension_never_overwrites_another_existing_path_without_confirmation() { + let temp = Temp::new(); + let path = temp.path("existing.figcraft"); + std::fs::write(&path, b"existing document").unwrap(); + let (mut app, _) = app(Some(temp.path("existing"))); + app.save_as_dialog(); + app.poll_dialog(&egui::Context::default()); + assert_eq!(std::fs::read(&path).unwrap(), b"existing document"); + assert!(!temp.path("existing").exists()); + assert!(app.session.ds().unwrap().path.is_none()); + assert!(app.session.ds().unwrap().is_dirty()); + assert_eq!(toast(&app), "A .figcraft file with this name already exists. Choose its full name to confirm replacement."); +} + +#[test] +fn fig_save_results_report_the_actual_name_and_plural_warning_count_without_marking_native_state_saved() { + let (mut app, _) = app(None); + let before = app.session.doc().unwrap().clone(); + app.session.ds_mut().unwrap().path = Some("working.figcraft".into()); + file_saved(&mut app, &json!({"format": "fig", "path": "written.fig", "warnings": ["synthetic warning", "another synthetic warning"]})); + assert_eq!(toast(&app), "Exported written.fig; 2 kinds of content were left out"); + assert_eq!(app.session.doc().unwrap(), &before); + assert_eq!(app.session.ds().unwrap().path.as_deref(), Some("working.figcraft")); + assert!(app.session.ds().unwrap().is_dirty()); +} + +#[test] +fn save_with_a_native_path_keeps_using_it_without_a_dialog() { + let temp = Temp::new(); + let (mut app, picks) = app(None); + let path = temp.path("working.figcraft"); + app.run("file.saveAs", json!({"path": path})).unwrap(); + app.run("shape.rectangle", json!({"width": 10, "height": 20})).unwrap(); + assert!(app.session.ds().unwrap().is_dirty()); + app.save(); + assert!(picks.lock().unwrap().is_empty()); + assert_eq!(app.session.ds().unwrap().path.as_deref(), path.to_str()); + assert!(!app.session.ds().unwrap().is_dirty()); + assert_eq!(toast(&app), "Saved"); +} + +#[test] +fn saving_legacy_fig_paths_keeps_the_success_toast_and_original_source_bytes() { + for legacy_name in ["legacy.fig", "legacy.FIG"] { + for destination in ["corrected.figcraft", "copy.fig"] { + let temp = Temp::new(); + let target = temp.path(destination); + let legacy = temp.path(legacy_name); + let (mut app, picks) = app(Some(target.clone())); + let original = temp.path("original.figcraft"); + app.run("file.saveAs", json!({"path": original})).unwrap(); + std::fs::rename(&original, &legacy).unwrap(); + let legacy_path = legacy.to_string_lossy().into_owned(); + app.session.ds_mut().unwrap().path = Some(legacy_path.clone()); + app.run("shape.rectangle", json!({"width": 10, "height": 20})).unwrap(); + let before = app.session.doc().unwrap().clone(); + let legacy_bytes = std::fs::read(&legacy).unwrap(); + app.save(); + app.poll_dialog(&egui::Context::default()); + assert_eq!(*picks.lock().unwrap(), expected_picker()); + assert_eq!(std::fs::read(&legacy).unwrap(), legacy_bytes); + assert!(target.is_file()); + assert!(app.ui.last_error.is_none()); + assert_eq!(app.session.doc().unwrap(), &before); + if destination == "corrected.figcraft" { + assert_eq!(toast(&app), "Saved corrected.figcraft"); + assert_eq!(app.session.ds().unwrap().path.as_deref(), target.to_str()); + assert!(!app.session.ds().unwrap().is_dirty()); + } else { + assert_eq!(toast(&app), "Exported copy.fig"); + assert_eq!(app.session.ds().unwrap().path.as_deref(), Some(legacy_path.as_str())); + assert!(app.session.ds().unwrap().is_dirty()); + } + } + } +} + +#[test] +fn failed_writes_surface_the_engine_error_and_keep_the_document_dirty() { + let temp = Temp::new(); + for extension in ["figcraft", "fig"] { + let path = temp.path(&format!("missing/copy.{extension}")); + let (mut app, _) = app(Some(path.clone())); + let before = app.session.doc().unwrap().clone(); + app.save_as_dialog(); + app.poll_dialog(&egui::Context::default()); + assert!(!path.exists()); + assert_eq!(app.session.doc().unwrap(), &before); + assert!(app.session.ds().unwrap().path.is_none()); + assert!(app.session.ds().unwrap().is_dirty()); + assert_eq!(app.ui.last_error.as_deref(), Some(toast(&app))); + assert!(!toast(&app).starts_with("Saved") && !toast(&app).starts_with("Exported")); + } +} + +#[test] +fn an_empty_destination_is_rejected_without_writing_or_changing_save_state() { + let (mut app, _) = app(Some(PathBuf::new())); + let before = app.session.doc().unwrap().clone(); + app.save_as_dialog(); + app.poll_dialog(&egui::Context::default()); + assert_eq!(app.session.doc().unwrap(), &before); + assert!(app.session.ds().unwrap().path.is_none()); + assert!(app.session.ds().unwrap().is_dirty()); + assert_eq!(toast(&app), "Use the .figcraft or .fig extension."); +} + +#[test] +fn save_as_with_no_document_never_opens_a_dialog() { + let (picker, picks) = picker(None); + let mut app = FigApp::new(Session::empty(), Box::new(picker)); + app.save_as_dialog(); + app.poll_dialog(&egui::Context::default()); + assert!(picks.lock().unwrap().is_empty()); + assert!(app.ui.toast.is_none()); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d8e51711..c25e33ea 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -60,6 +60,13 @@ export limit, not a native document nesting limit. ### Export to `.fig` (experimental) +File ▸ Save as offers `.figcraft` and `.fig` as separate file types in the system dialog. A `.fig` +destination writes the same experimental export copy of the document that is shown (`file.save` and +`file.saveAs` with a path ending in `.fig` do the same) and reports what was left out. The file's own +path, its unsaved changes, versions, branches and undo history are not touched, and a shown earlier +version stays shown. Other apps may not open every exported `.fig` file. A plain Save never writes over +a file whose saved path ends in `.fig`; it asks for a destination instead. + `file.export {format: "fig"}` (or a `path` ending in `.fig`), File ▸ Export… ▸ .fig (experimental), `figcraft-cli export -o .fig` (and `convert`) and MCP's `export {format: "fig"}` write the whole document. The writer (`crates/format/src/fig/export.rs`) works from the same public format From 01daf92fc3373c108370b1a5649c2b17a1db8bb3 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 00:31:32 +0200 Subject: [PATCH 006/118] M1: show an import report after opening a file Opening or importing a .fig or REST JSON file, or an SVG that has notes, from the app (File > Open or Import, a recent file, a drop, a paste, the system's open event) now shows an Import report window with the document's name, its page, layer and image counts and the compatibility notes the importer collected, instead of leaving them only in the command result. Commands sent over the control channel or MCP never open it. file.open and file.import return the counts as `summary`; ui.inspect reports the open report as `importReport`. The fixed notes the importers emit are translated (German and Spanish) and shown with a capital first letter; other notes, such as SVG's, show as written. The report goes away with its document. Escape or Close dismisses it. --- apps/figcraft/src/apple_events.rs | 2 +- crates/engine/src/cmd/file.rs | 15 +- crates/ui-egui/locales/de.toml | 72 +++++++ crates/ui-egui/locales/en.toml | 72 +++++++ crates/ui-egui/locales/es.toml | 72 +++++++ crates/ui-egui/src/dialogs.rs | 4 +- crates/ui-egui/src/home_recents.rs | 2 +- crates/ui-egui/src/import_report.rs | 246 ++++++++++++++++++++++ crates/ui-egui/src/import_report_tests.rs | 174 +++++++++++++++ crates/ui-egui/src/lib.rs | 21 +- crates/ui-egui/src/menus.rs | 13 +- crates/ui-egui/src/pending_open.rs | 4 +- docs/control-protocol.md | 8 +- docs/mcp.md | 2 +- 14 files changed, 688 insertions(+), 19 deletions(-) create mode 100644 crates/ui-egui/src/import_report.rs create mode 100644 crates/ui-egui/src/import_report_tests.rs diff --git a/apps/figcraft/src/apple_events.rs b/apps/figcraft/src/apple_events.rs index 6b792094..41ae5cc3 100644 --- a/apps/figcraft/src/apple_events.rs +++ b/apps/figcraft/src/apple_events.rs @@ -50,7 +50,7 @@ pub fn poll(inbox: &Inbox, fig: &mut FigApp, ctx: &egui::Context) { /// Open `paths` as File › Open does; a file that fails shows its error and the others still open. pub fn open_documents(fig: &mut FigApp, paths: Vec) { for p in paths { - if fig.run("file.open", json!({"path": p.to_string_lossy()})).is_some() { + if fig.run_user("file.open", json!({"path": p.to_string_lossy()})).is_some() { fig.ui.home = false; } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 54348372..8319dee2 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { spec!( "file.open", "Open", - "{path, variables? (REST: the local variables JSON as an object, JSON text or a path; default `.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`", + "{path, variables? (REST: the local variables JSON as an object, JSON text or a path; default `.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)", false, open ), @@ -74,7 +74,7 @@ pub fn specs() -> Vec { spec!( "file.import", "Import", - "{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`)", + "{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)", true, import ), @@ -461,6 +461,11 @@ fn file_stem(path: &str) -> String { std::path::Path::new(path).file_stem().and_then(|e| e.to_str()).unwrap_or("Untitled").to_string() } +/// The counts the import report shows for a freshly loaded document. +fn import_summary(doc: &Document) -> Value { + json!({"name": doc.name, "pages": doc.pages.len(), "layers": doc.nodes().filter(|n| !n.is_page()).count(), "images": doc.images.len()}) +} + /// Load a document from any supported file (REST API file JSON images come from an `images` /// folder next to the file). pub fn load(path: &str) -> Result<(Document, Vec)> { @@ -672,9 +677,10 @@ fn open(s: &mut Session, p: &Value) -> Result { more => format!("{}: {} problems were repaired or left out while opening it, e.g. {}", file_stem(&path), more.len(), more[0]), }); } + let summary = import_summary(&loaded.doc); s.docs.push(DocState::new(loaded.doc, native.then_some(path)).with_versions(loaded.versions).with_branches(loaded.branches)); s.active = s.docs.len() - 1; - let mut out = json!({"index": s.active}); + let mut out = json!({"index": s.active, "summary": summary}); out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues); Ok(out) } @@ -1215,8 +1221,9 @@ fn import(s: &mut Session, p: &Value) -> Result { }; let sibling = if variables.is_none() { path.as_deref().and_then(sibling_variables) } else { None }; let (other, rest) = load_with(&bytes, &name, variables.as_ref().or(sibling.as_ref()), images)?; + let summary = import_summary(&other.doc); let pages = append_pages(s.doc_mut()?, &other.doc)?; - let mut out = json!({"pages": pages}); + let mut out = json!({"pages": pages, "summary": summary}); if rest { out["warnings"] = json!(other.issues); } diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index cd1e3482..c7bd13f7 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -261,6 +261,7 @@ status = "draft — needs native review" "Commits" = "Commits" "Company" = "Unternehmen" "Compare changes" = "Änderungen vergleichen" +"Compatibility notes" = "Hinweise zur Kompatibilität" "Completed" = "Abgeschlossen" "Component" = "Komponente" "Component name" = "Komponentenname" @@ -464,6 +465,7 @@ status = "draft — needs native review" "False" = "Falsch" "Fetching from the server…" = "Wird vom Server abgerufen…" "FigCraft couldn't open its window: {error}" = "FigCraft konnte sein Fenster nicht öffnen: {error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft unterstützt noch nicht alle Funktionen dieser Dateien. Die Hinweise nennen, was in der importierten Kopie weggelassen oder geändert wurde; die Originaldatei bleibt unverändert." "File" = "Datei" "File name" = "Dateiname" "File published" = "Datei veröffentlicht" @@ -580,11 +582,14 @@ status = "draft — needs native review" "If {condition}" = "Wenn {condition}" "Ignore auto layout (absolute position)" = "Auto-Layout ignorieren (absolute Position)" "Image" = "Bild" +"Images" = "Bilder" "Import" = "Importieren" "Import path" = "Importpfad" "Import plugin from manifest" = "Plugin aus Manifest importieren" "Import plugin from manifest…" = "Plugin aus Manifest importieren…" +"Import report" = "Importbericht" "Import variables" = "Variablen importieren" +"Imported document" = "Importiertes Dokument" "Imported {name}" = "{name} importiert" "Imported {n} new variables, updated {updated}" = { one = "{n} neue Variable importiert, {updated} aktualisiert", other = "{n} neue Variablen importiert, {updated} aktualisiert" } "Import…" = "Importieren…" @@ -774,6 +779,7 @@ status = "draft — needs native review" "No colour variables fit here." = "Hier passen keine Farbvariablen." "No colour variables yet. Create them in Local variables." = "Noch keine Farbvariablen. Erstellen Sie sie unter Lokale Variablen." "No comments yet" = "Noch keine Kommentare" +"No compatibility warnings reported" = "Keine Kompatibilitätswarnungen gemeldet" "No components yet. Select layers and press {keys}." = "Noch keine Komponenten. Wählen Sie Ebenen aus und drücken Sie {keys}." "No conflicts." = "Keine Konflikte." "No contributor data was built into this copy." = "Diese Kopie enthält keine Daten zu Mitwirkenden." @@ -860,6 +866,7 @@ status = "draft — needs native review" "Open overlay" = "Overlay öffnen" "Open prototype preview" = "Prototyp-Vorschau öffnen" "Open variables ({count})" = "Variablen öffnen ({count})" +"Opened as a new document" = "Als neues Dokument geöffnet" "Opened “{name}” in a new tab" = "„{name}“ in neuem Tab geöffnet" "Opening the file…" = "Datei wird geöffnet…" "Opening {n} files as new files" = { one = "Wird als neue Datei geöffnet", other = "{n} Dateien werden als neue Dateien geöffnet" } @@ -885,6 +892,8 @@ status = "draft — needs native review" "Padding" = "Innenabstand" "Page" = "Seite" "Pages" = "Seiten" +"Pages added to the document" = "Seiten zum Dokument hinzugefügt" +"Placed on the current page" = "Auf der aktuellen Seite platziert" "Pages: {pages}" = "Seiten: {pages}" "Paint bucket" = "Farbeimer" "Pan the canvas" = "Arbeitsfläche verschieben" @@ -1435,25 +1444,87 @@ status = "draft — needs native review" "call any command its access allows" = "jeden Befehl aufrufen, den sein Zugriff erlaubt" "call only: {commands}" = "nur aufrufen: {commands}" "can edit the document" = "kann das Dokument bearbeiten" +"conditional prototype actions nested too deeply were skipped" = "zu tief verschachtelte bedingte Prototyp-Aktionen wurden übersprungen" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "bedingte Prototyp-Aktionen mit nicht auswertbaren Bedingungen wurden übersprungen" +"connector labels were skipped" = "Verbinderbeschriftungen wurden übersprungen" +"connectors whose ends can't be placed are empty" = "Verbinder, deren Enden sich nicht platzieren lassen, sind leer" "crop marks" = "Schnittmarken" +"damaged vector geometry was skipped" = "beschädigte Vektorgeometrie wurde übersprungen" +"damaged vector networks import as outlines" = "beschädigte Vektornetze werden als Umrisse importiert" "development: {path}" = "Entwicklung: {path}" +"document children other than pages were skipped" = "Dokumentinhalte, die keine Seiten sind, wurden übersprungen" "e.g. count + 1" = "z. B. count + 1" "e.g. count >= 3 and not done" = "z. B. count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "gewinkelte und gebogene Verbinder ohne gespeicherten Pfad werden als gerade Linien importiert" +"entries without a node type were skipped" = "Einträge ohne Knotentyp wurden übersprungen" "figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft://-Links aus Browser, Chat oder E-Mail öffnen diese App und treten der Sitzung bei." +"filled areas of a vector with their own fill use the layer's fill" = "gefüllte Bereiche eines Vektors mit eigener Füllung verwenden die Füllung der Ebene" "from {server}" = "von {server}" +"gamepad triggers without exactly one known controller button were skipped" = "Gamepad-Auslöser ohne genau eine bekannte Controllertaste wurden übersprungen" +"grid tracks of fixed or content size import as equal tracks" = "Rasterspuren mit fester oder inhaltsabhängiger Größe werden als gleich große Spuren importiert" "hidden" = "ausgeblendet" "iOS" = "iOS" +"instance swap properties whose default component isn't in the file were skipped" = "Instanztausch-Eigenschaften, deren Standardkomponente nicht in der Datei ist, wurden übersprungen" +"instances inside their own main component were imported as frames" = "Instanzen innerhalb ihrer eigenen Hauptkomponente wurden als Rahmen importiert" +"instances of components that aren't in the file show no layers" = "Instanzen von Komponenten, die nicht in der Datei sind, zeigen keine Ebenen" +"instances of components that aren't in the file were imported as frames" = "Instanzen von Komponenten, die nicht in der Datei sind, wurden als Rahmen importiert" +"instances that would nest a component inside itself were imported as frames" = "Instanzen, die eine Komponente in sich selbst verschachteln würden, wurden als Rahmen importiert" +"instances without a main component were imported as empty frames" = "Instanzen ohne Hauptkomponente wurden als leere Rahmen importiert" +"invalid or oversized vector networks import as outlines" = "ungültige oder zu große Vektornetze werden als Umrisse importiert" +"invalid or oversized vector path data was skipped" = "ungültige oder zu große Vektorpfaddaten wurden übersprungen" +"key triggers without a supported key were skipped" = "Tastenauslöser ohne unterstützte Taste wurden übersprungen" +"key up triggers import as key presses" = "Auslöser beim Loslassen einer Taste werden als Tastendruck importiert" +"layers without a position were placed at their parent's origin" = "Ebenen ohne Position wurden am Ursprung ihres übergeordneten Elements platziert" +"layers without a type were skipped" = "Ebenen ohne Typ wurden übersprungen" "locked" = "gesperrt" "no bleed" = "kein Anschnitt" "nobody else here yet" = "noch niemand sonst hier" "now" = "jetzt" "or" = "oder" +"points with their own stroke join use the layer's" = "Punkte mit eigener Konturecke verwenden die der Ebene" +"prototype actions of unknown types were skipped" = "Prototyp-Aktionen unbekannter Art wurden übersprungen" +"prototype actions without a destination in the file were skipped" = "Prototyp-Aktionen ohne Ziel in der Datei wurden übersprungen" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "Prototyp-Ausdrücke mit Variablen, die nicht im Variablen-JSON sind, wurden übersprungen" +"prototype expressions with unsupported functions were skipped" = "Prototyp-Ausdrücke mit nicht unterstützten Funktionen wurden übersprungen" +"prototype interactions without supported actions were skipped" = "Prototyp-Interaktionen ohne unterstützte Aktionen wurden übersprungen" +"prototype navigations of unknown types were skipped" = "Prototyp-Navigationen unbekannter Art wurden übersprungen" +"prototype transitions of unknown types play instantly" = "Prototyp-Übergänge unbekannter Art werden sofort ausgeführt" +"prototype triggers of unknown types were skipped" = "Prototyp-Auslöser unbekannter Art wurden übersprungen" "put a widget on the canvas" = "ein Widget auf die Arbeitsfläche setzen" "read and edit the document" = "das Dokument lesen und bearbeiten" "read the document" = "das Dokument lesen" "reads the document" = "liest das Dokument" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "Aktionen „Variable festlegen“ für Variablen, die nicht im Variablen-JSON sind, wurden übersprungen" +"set-variable actions with values that can't be evaluated were skipped" = "Aktionen „Variable festlegen“ mit nicht auswertbaren Werten wurden übersprungen" +"set-variable actions with values that don't fit the variable were skipped" = "Aktionen „Variable festlegen“ mit Werten, die nicht zur Variable passen, wurden übersprungen" +"set-variable actions without a value were skipped" = "Aktionen „Variable festlegen“ ohne Wert wurden übersprungen" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "Aktionen „Variablenmodus festlegen“ für Sammlungen oder Modi, die nicht im Variablen-JSON sind, wurden übersprungen" +"stars and polygons without point counts or geometry use the default point count" = "Sterne und Polygone ohne Punktanzahl oder Geometrie verwenden die Standard-Punktanzahl" +"text style overrides refer to missing table entries" = "Textstil-Überschreibungen verweisen auf fehlende Tabelleneinträge" +"the file has no pages" = "die Datei hat keine Seiten" "the plugin" = "das Plugin" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "das Variablen-JSON enthält keine `variableCollections` (erwartet wurde die Antwort mit den lokalen Variablen) und wurde ignoriert" "this session only" = "nur diese Sitzung" +"unknown component property types were skipped" = "unbekannte Komponenteneigenschaftstypen wurden übersprungen" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "Variablen-Aliase auf Variablen, die nicht im Variablen-JSON sind (oder einen anderen Typ haben), wurden übersprungen" +"variable bindings to properties the layer doesn't have were skipped" = "Variablenbindungen an Eigenschaften, die die Ebene nicht hat, wurden übersprungen" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "Variablenbindungen an Variablen, die nicht im Variablen-JSON sind (Bibliotheksvariablen), wurden übersprungen" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "Variablenbindungen wurden übersprungen: Das Datei-JSON enthält keine Variablendefinitionen (übergeben Sie das lokale Variablen-JSON als `variables` oder betten Sie dessen `meta` neben dem Datei-JSON ein)" +"variable bindings whose variable type doesn't fit the property were skipped" = "Variablenbindungen, deren Variablentyp nicht zur Eigenschaft passt, wurden übersprungen" +"variable collections without modes were skipped" = "Variablensammlungen ohne Modi wurden übersprungen" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "Variablenmodus-Abfragen in Prototyp-Ausdrücken, die nicht aus einer Variable und einem ihrer Modi bestehen, wurden übersprungen" +"variable modes of collections that aren't in the file were skipped" = "Variablenmodi von Sammlungen, die nicht in der Datei sind, wurden übersprungen" +"variable modes of collections that aren't in the variables JSON were skipped" = "Variablenmodi von Sammlungen, die nicht im Variablen-JSON sind, wurden übersprungen" +"variable values that don't match their type (or composed colours) were skipped" = "Variablenwerte, die nicht zu ihrem Typ passen (oder zusammengesetzte Farben), wurden übersprungen" +"variables of unknown types were skipped" = "Variablen unbekannter Typen wurden übersprungen" +"variables whose collection isn't in the variables JSON were skipped" = "Variablen, deren Sammlung nicht im Variablen-JSON ist, wurden übersprungen" +"variables without readable values were skipped" = "Variablen ohne lesbare Werte wurden übersprungen" +"vectors without geometry are empty" = "Vektoren ohne Geometrie sind leer" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "Vektoren ohne Geometrie sind leer (exportieren Sie das Datei-JSON mit geometry=paths)" +"very long text was cut to 1,048,576 characters" = "sehr langer Text wurde auf 1.048.576 Zeichen gekürzt" +"very long vector paths were skipped" = "sehr lange Vektorpfade wurden übersprungen" +"video actions are not supported and were skipped" = "Videoaktionen werden nicht unterstützt und wurden übersprungen" +"video triggers are not supported and were skipped" = "Videoauslöser werden nicht unterstützt und wurden übersprungen" "{instances} in {files}" = "{instances} in {files}" "{language}: draft translation, waiting for a native speaker's review." = "{language}: Übersetzungsentwurf, wartet auf die Prüfung durch Muttersprachler." "{length} bleed" = "{length} Anschnitt" @@ -1496,6 +1567,7 @@ status = "draft — needs native review" "{n} matches" = { one = "{n} Treffer", other = "{n} Treffer" } "{n} minutes ago" = { one = "vor {n} Minute", other = "vor {n} Minuten" } "{n} months ago" = { one = "vor {n} Monat", other = "vor {n} Monaten" } +"{n} more notes omitted from this report" = { one = "{n} weitere Notiz in diesem Bericht nicht aufgeführt", other = "{n} weitere Notizen in diesem Bericht nicht aufgeführt" } "{n} of {total}" = "{n} von {total}" "{n} people joined" = { one = "{n} Person ist beigetreten", other = "{n} Personen sind beigetreten" } "{n} preferred components" = { one = "{n} bevorzugte Komponente", other = "{n} bevorzugte Komponenten" } diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 3c127fc6..c3e75570 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -270,6 +270,7 @@ name = "English" "Commits" = "Commits" "Company" = "Company" "Compare changes" = "Compare changes" +"Compatibility notes" = "Compatibility notes" "Completed" = "Completed" "Component" = "Component" "Component name" = "Component name" @@ -482,6 +483,7 @@ name = "English" "Feb" = "Feb" "Fetching from the server…" = "Fetching from the server…" "FigCraft couldn't open its window: {error}" = "FigCraft couldn't open its window: {error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." "File" = "File" "File name" = "File name" "File not found" = "File not found" @@ -606,11 +608,14 @@ name = "English" "If {condition}" = "If {condition}" "Ignore auto layout (absolute position)" = "Ignore auto layout (absolute position)" "Image" = "Image" +"Images" = "Images" "Import" = "Import" "Import path" = "Import path" "Import plugin from manifest" = "Import plugin from manifest" "Import plugin from manifest…" = "Import plugin from manifest…" +"Import report" = "Import report" "Import variables" = "Import variables" +"Imported document" = "Imported document" "Imported {name}" = "Imported {name}" "Imported {n} new variables, updated {updated}" = { one = "Imported {n} new variable, updated {updated}", other = "Imported {n} new variables, updated {updated}" } "Import…" = "Import…" @@ -812,6 +817,7 @@ name = "English" "No colour variables fit here." = "No colour variables fit here." "No colour variables yet. Create them in Local variables." = "No colour variables yet. Create them in Local variables." "No comments yet" = "No comments yet" +"No compatibility warnings reported" = "No compatibility warnings reported" "No components yet. Select layers and press {keys}." = "No components yet. Select layers and press {keys}." "No conflicts." = "No conflicts." "No contributor data was built into this copy." = "No contributor data was built into this copy." @@ -903,6 +909,7 @@ name = "English" "Open overlay" = "Open overlay" "Open prototype preview" = "Open prototype preview" "Open variables ({count})" = "Open variables ({count})" +"Opened as a new document" = "Opened as a new document" "Opened “{name}” in a new tab" = "Opened “{name}” in a new tab" "Opening the file…" = "Opening the file…" "Opening {n} files as new files" = { one = "Opening as a new file", other = "Opening {n} files as new files" } @@ -929,6 +936,8 @@ name = "English" "Padding" = "Padding" "Page" = "Page" "Pages" = "Pages" +"Pages added to the document" = "Pages added to the document" +"Placed on the current page" = "Placed on the current page" "Pages: {pages}" = "Pages: {pages}" "Paint bucket" = "Paint bucket" "Pan the canvas" = "Pan the canvas" @@ -1506,26 +1515,88 @@ name = "English" "call any command its access allows" = "call any command its access allows" "call only: {commands}" = "call only: {commands}" "can edit the document" = "can edit the document" +"conditional prototype actions nested too deeply were skipped" = "conditional prototype actions nested too deeply were skipped" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "conditional prototype actions with conditions that can't be evaluated were skipped" +"connector labels were skipped" = "connector labels were skipped" +"connectors whose ends can't be placed are empty" = "connectors whose ends can't be placed are empty" "crop marks" = "crop marks" +"damaged vector geometry was skipped" = "damaged vector geometry was skipped" +"damaged vector networks import as outlines" = "damaged vector networks import as outlines" "development: {path}" = "development: {path}" +"document children other than pages were skipped" = "document children other than pages were skipped" "e.g. count + 1" = "e.g. count + 1" "e.g. count >= 3 and not done" = "e.g. count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "elbowed and curved connectors without a saved path import as straight lines" +"entries without a node type were skipped" = "entries without a node type were skipped" "figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft:// links from a browser, chat or email open this app and join the session." +"filled areas of a vector with their own fill use the layer's fill" = "filled areas of a vector with their own fill use the layer's fill" "from {server}" = "from {server}" +"gamepad triggers without exactly one known controller button were skipped" = "gamepad triggers without exactly one known controller button were skipped" +"grid tracks of fixed or content size import as equal tracks" = "grid tracks of fixed or content size import as equal tracks" "hidden" = "hidden" "iOS" = "iOS" +"instance swap properties whose default component isn't in the file were skipped" = "instance swap properties whose default component isn't in the file were skipped" +"instances inside their own main component were imported as frames" = "instances inside their own main component were imported as frames" +"instances of components that aren't in the file show no layers" = "instances of components that aren't in the file show no layers" +"instances of components that aren't in the file were imported as frames" = "instances of components that aren't in the file were imported as frames" +"instances that would nest a component inside itself were imported as frames" = "instances that would nest a component inside itself were imported as frames" +"instances without a main component were imported as empty frames" = "instances without a main component were imported as empty frames" +"invalid or oversized vector networks import as outlines" = "invalid or oversized vector networks import as outlines" +"invalid or oversized vector path data was skipped" = "invalid or oversized vector path data was skipped" "just now" = "just now" +"key triggers without a supported key were skipped" = "key triggers without a supported key were skipped" +"key up triggers import as key presses" = "key up triggers import as key presses" +"layers without a position were placed at their parent's origin" = "layers without a position were placed at their parent's origin" +"layers without a type were skipped" = "layers without a type were skipped" "locked" = "locked" "no bleed" = "no bleed" "nobody else here yet" = "nobody else here yet" "now" = "now" "or" = "or" +"points with their own stroke join use the layer's" = "points with their own stroke join use the layer's" +"prototype actions of unknown types were skipped" = "prototype actions of unknown types were skipped" +"prototype actions without a destination in the file were skipped" = "prototype actions without a destination in the file were skipped" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "prototype expressions on variables that aren't in the variables JSON were skipped" +"prototype expressions with unsupported functions were skipped" = "prototype expressions with unsupported functions were skipped" +"prototype interactions without supported actions were skipped" = "prototype interactions without supported actions were skipped" +"prototype navigations of unknown types were skipped" = "prototype navigations of unknown types were skipped" +"prototype transitions of unknown types play instantly" = "prototype transitions of unknown types play instantly" +"prototype triggers of unknown types were skipped" = "prototype triggers of unknown types were skipped" "put a widget on the canvas" = "put a widget on the canvas" "read and edit the document" = "read and edit the document" "read the document" = "read the document" "reads the document" = "reads the document" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "set-variable actions on variables that aren't in the variables JSON were skipped" +"set-variable actions with values that can't be evaluated were skipped" = "set-variable actions with values that can't be evaluated were skipped" +"set-variable actions with values that don't fit the variable were skipped" = "set-variable actions with values that don't fit the variable were skipped" +"set-variable actions without a value were skipped" = "set-variable actions without a value were skipped" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" +"stars and polygons without point counts or geometry use the default point count" = "stars and polygons without point counts or geometry use the default point count" +"text style overrides refer to missing table entries" = "text style overrides refer to missing table entries" +"the file has no pages" = "the file has no pages" "the plugin" = "the plugin" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" "this session only" = "this session only" +"unknown component property types were skipped" = "unknown component property types were skipped" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" +"variable bindings to properties the layer doesn't have were skipped" = "variable bindings to properties the layer doesn't have were skipped" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "variable bindings to variables that aren't in the variables JSON (library variables) were skipped" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" +"variable bindings whose variable type doesn't fit the property were skipped" = "variable bindings whose variable type doesn't fit the property were skipped" +"variable collections without modes were skipped" = "variable collections without modes were skipped" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" +"variable modes of collections that aren't in the file were skipped" = "variable modes of collections that aren't in the file were skipped" +"variable modes of collections that aren't in the variables JSON were skipped" = "variable modes of collections that aren't in the variables JSON were skipped" +"variable values that don't match their type (or composed colours) were skipped" = "variable values that don't match their type (or composed colours) were skipped" +"variables of unknown types were skipped" = "variables of unknown types were skipped" +"variables whose collection isn't in the variables JSON were skipped" = "variables whose collection isn't in the variables JSON were skipped" +"variables without readable values were skipped" = "variables without readable values were skipped" +"vectors without geometry are empty" = "vectors without geometry are empty" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "vectors without geometry are empty (export the file JSON with geometry=paths)" +"very long text was cut to 1,048,576 characters" = "very long text was cut to 1,048,576 characters" +"very long vector paths were skipped" = "very long vector paths were skipped" +"video actions are not supported and were skipped" = "video actions are not supported and were skipped" +"video triggers are not supported and were skipped" = "video triggers are not supported and were skipped" "yesterday" = "yesterday" "{instances} in {files}" = "{instances} in {files}" "{language}: draft translation, waiting for a native speaker's review." = "{language}: draft translation, waiting for a native speaker's review." @@ -1570,6 +1641,7 @@ name = "English" "{n} matches" = { one = "{n} match", other = "{n} matches" } "{n} minutes ago" = { one = "{n} minute ago", other = "{n} minutes ago" } "{n} months ago" = { one = "{n} month ago", other = "{n} months ago" } +"{n} more notes omitted from this report" = { one = "{n} more note omitted from this report", other = "{n} more notes omitted from this report" } "{n} of {total}" = "{n} of {total}" "{n} people joined" = { one = "{n} person joined", other = "{n} people joined" } "{n} preferred components" = { one = "{n} preferred component", other = "{n} preferred components" } diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 901283f1..49dd765f 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -8,6 +8,7 @@ name = "Español" status = "draft — needs native review" [ui] +"Compatibility notes" = "Notas de compatibilidad" "Couldn't download the variables" = "No se pudieron descargar las variables" "% of all commits" = "% del total de commits" @@ -483,6 +484,7 @@ status = "draft — needs native review" "Feb" = "feb" "Fetching from the server…" = "Obteniendo del servidor…" "FigCraft couldn't open its window: {error}" = "FigCraft no pudo abrir su ventana: {error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft aún no admite todas las funciones de estos archivos. Las notas indican qué se omitió o cambió en la copia importada; el archivo original no se modifica." "File" = "Archivo" "File name" = "Nombre del archivo" "File not found" = "No se encuentra el archivo" @@ -607,11 +609,14 @@ status = "draft — needs native review" "If {condition}" = "Si {condition}" "Ignore auto layout (absolute position)" = "Ignorar diseño automático (posición absoluta)" "Image" = "Imagen" +"Images" = "Imágenes" "Import" = "Importar" "Import path" = "Ruta de importación" "Import plugin from manifest" = "Importar plugin desde manifiesto" "Import plugin from manifest…" = "Importar plugin desde manifiesto…" +"Import report" = "Informe de importación" "Import variables" = "Importar variables" +"Imported document" = "Documento importado" "Imported {name}" = "{name} importado" "Imported {n} new variables, updated {updated}" = { one = "Variables importadas: {n} nueva; actualizadas: {updated}", other = "Variables importadas: {n} nuevas; actualizadas: {updated}" } "Import…" = "Importar…" @@ -813,6 +818,7 @@ status = "draft — needs native review" "No colour variables fit here." = "Ninguna variable de color encaja aquí." "No colour variables yet. Create them in Local variables." = "Aún no hay variables de color. Créalas en Variables locales." "No comments yet" = "Aún no hay comentarios" +"No compatibility warnings reported" = "No se han registrado advertencias de compatibilidad" "No components yet. Select layers and press {keys}." = "Aún no hay componentes. Selecciona capas y pulsa {keys}." "No conflicts." = "Sin conflictos." "No contributor data was built into this copy." = "Esta copia no incluye datos de colaboradores." @@ -904,6 +910,7 @@ status = "draft — needs native review" "Open overlay" = "Abrir superposición" "Open prototype preview" = "Abrir vista previa del prototipo" "Open variables ({count})" = "Abrir variables ({count})" +"Opened as a new document" = "Abierto como un documento nuevo" "Opened “{name}” in a new tab" = "“{name}” abierta en una pestaña nueva" "Opening the file…" = "Abriendo el archivo…" "Opening {n} files as new files" = { one = "Abriendo como archivo nuevo", other = "Abriendo {n} archivos como archivos nuevos" } @@ -930,6 +937,8 @@ status = "draft — needs native review" "Padding" = "Margen interno" "Page" = "Página" "Pages" = "Páginas" +"Pages added to the document" = "Páginas añadidas al documento" +"Placed on the current page" = "Colocado en la página actual" "Pages: {pages}" = "Páginas: {pages}" "Paint bucket" = "Bote de pintura" "Pan the canvas" = "Desplazar el lienzo" @@ -1507,26 +1516,88 @@ status = "draft — needs native review" "call any command its access allows" = "llamar a cualquier comando que su acceso permita" "call only: {commands}" = "llamar solo a: {commands}" "can edit the document" = "puede editar el documento" +"conditional prototype actions nested too deeply were skipped" = "se omitieron las acciones condicionales de prototipo anidadas a demasiada profundidad" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "se omitieron las acciones condicionales de prototipo con condiciones que no se pueden evaluar" +"connector labels were skipped" = "se omitieron las etiquetas de los conectores" +"connectors whose ends can't be placed are empty" = "los conectores cuyos extremos no se pueden colocar están vacíos" "crop marks" = "marcas de corte" +"damaged vector geometry was skipped" = "se omitió la geometría vectorial dañada" +"damaged vector networks import as outlines" = "las redes vectoriales dañadas se importan como contornos" "development: {path}" = "desarrollo: {path}" +"document children other than pages were skipped" = "se omitieron los elementos del documento que no son páginas" "e.g. count + 1" = "p. ej. count + 1" "e.g. count >= 3 and not done" = "p. ej. count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "los conectores angulares y curvos sin trazado guardado se importan como líneas rectas" +"entries without a node type were skipped" = "se omitieron las entradas sin tipo de nodo" "figcraft:// links from a browser, chat or email open this app and join the session." = "Los enlaces figcraft:// de un navegador, chat o correo abren esta app y se unen a la sesión." +"filled areas of a vector with their own fill use the layer's fill" = "las áreas rellenas de un vector con relleno propio usan el relleno de la capa" "from {server}" = "de {server}" +"gamepad triggers without exactly one known controller button were skipped" = "se omitieron los desencadenantes de mando sin exactamente un botón de mando conocido" +"grid tracks of fixed or content size import as equal tracks" = "las pistas de cuadrícula de tamaño fijo o según el contenido se importan como pistas iguales" "hidden" = "oculta" "iOS" = "iOS" +"instance swap properties whose default component isn't in the file were skipped" = "se omitieron las propiedades de intercambio de instancia cuyo componente predeterminado no está en el archivo" +"instances inside their own main component were imported as frames" = "las instancias dentro de su propio componente principal se importaron como marcos" +"instances of components that aren't in the file show no layers" = "las instancias de componentes que no están en el archivo no muestran capas" +"instances of components that aren't in the file were imported as frames" = "las instancias de componentes que no están en el archivo se importaron como marcos" +"instances that would nest a component inside itself were imported as frames" = "las instancias que anidarían un componente dentro de sí mismo se importaron como marcos" +"instances without a main component were imported as empty frames" = "las instancias sin componente principal se importaron como marcos vacíos" +"invalid or oversized vector networks import as outlines" = "las redes vectoriales no válidas o demasiado grandes se importan como contornos" +"invalid or oversized vector path data was skipped" = "se omitieron los datos de trazados vectoriales no válidos o demasiado grandes" "just now" = "justo ahora" +"key triggers without a supported key were skipped" = "se omitieron los desencadenantes de tecla sin una tecla compatible" +"key up triggers import as key presses" = "los desencadenantes al soltar una tecla se importan como pulsaciones de tecla" +"layers without a position were placed at their parent's origin" = "las capas sin posición se colocaron en el origen de su elemento padre" +"layers without a type were skipped" = "se omitieron las capas sin tipo" "locked" = "bloqueada" "no bleed" = "sin sangrado" "nobody else here yet" = "aún no hay nadie más" "now" = "ahora" "or" = "o" +"points with their own stroke join use the layer's" = "los puntos con su propia unión de trazo usan la de la capa" +"prototype actions of unknown types were skipped" = "se omitieron las acciones de prototipo de tipos desconocidos" +"prototype actions without a destination in the file were skipped" = "se omitieron las acciones de prototipo sin destino en el archivo" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "se omitieron las expresiones de prototipo con variables que no están en el JSON de variables" +"prototype expressions with unsupported functions were skipped" = "se omitieron las expresiones de prototipo con funciones no compatibles" +"prototype interactions without supported actions were skipped" = "se omitieron las interacciones de prototipo sin acciones compatibles" +"prototype navigations of unknown types were skipped" = "se omitieron las navegaciones de prototipo de tipos desconocidos" +"prototype transitions of unknown types play instantly" = "las transiciones de prototipo de tipos desconocidos se reproducen al instante" +"prototype triggers of unknown types were skipped" = "se omitieron los desencadenantes de prototipo de tipos desconocidos" "put a widget on the canvas" = "poner un widget en el lienzo" "read and edit the document" = "leer y editar el documento" "read the document" = "leer el documento" "reads the document" = "lee el documento" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "se omitieron las acciones «Establecer variable» sobre variables que no están en el JSON de variables" +"set-variable actions with values that can't be evaluated were skipped" = "se omitieron las acciones «Establecer variable» con valores que no se pueden evaluar" +"set-variable actions with values that don't fit the variable were skipped" = "se omitieron las acciones «Establecer variable» con valores que no encajan con la variable" +"set-variable actions without a value were skipped" = "se omitieron las acciones «Establecer variable» sin valor" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "se omitieron las acciones «Establecer modo de variable» sobre colecciones o modos que no están en el JSON de variables" +"stars and polygons without point counts or geometry use the default point count" = "las estrellas y los polígonos sin número de puntos ni geometría usan el número de puntos predeterminado" +"text style overrides refer to missing table entries" = "las anulaciones de estilo de texto hacen referencia a entradas de tabla que faltan" +"the file has no pages" = "el archivo no tiene páginas" "the plugin" = "el plugin" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "el JSON de variables no tiene `variableCollections` (se esperaba la respuesta de variables locales) y se ignoró" "this session only" = "solo esta sesión" +"unknown component property types were skipped" = "se omitieron los tipos de propiedad de componente desconocidos" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "se omitieron los alias a variables que no están en el JSON de variables (o son de otro tipo)" +"variable bindings to properties the layer doesn't have were skipped" = "se omitieron las vinculaciones de variables a propiedades que la capa no tiene" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "se omitieron las vinculaciones a variables que no están en el JSON de variables (variables de biblioteca)" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "se omitieron las vinculaciones de variables: el JSON del archivo no tiene definiciones de variables (pasa el JSON de variables locales como `variables` o incrusta su `meta` junto al JSON del archivo)" +"variable bindings whose variable type doesn't fit the property were skipped" = "se omitieron las vinculaciones de variables cuyo tipo no encaja con la propiedad" +"variable collections without modes were skipped" = "se omitieron las colecciones de variables sin modos" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "se omitieron las consultas de modo de variable en expresiones de prototipo que no son una variable y uno de sus modos" +"variable modes of collections that aren't in the file were skipped" = "se omitieron los modos de variable de colecciones que no están en el archivo" +"variable modes of collections that aren't in the variables JSON were skipped" = "se omitieron los modos de variable de colecciones que no están en el JSON de variables" +"variable values that don't match their type (or composed colours) were skipped" = "se omitieron los valores de variable que no coinciden con su tipo (o colores compuestos)" +"variables of unknown types were skipped" = "se omitieron las variables de tipos desconocidos" +"variables whose collection isn't in the variables JSON were skipped" = "se omitieron las variables cuya colección no está en el JSON de variables" +"variables without readable values were skipped" = "se omitieron las variables sin valores legibles" +"vectors without geometry are empty" = "los vectores sin geometría están vacíos" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "los vectores sin geometría están vacíos (exporta el JSON del archivo con geometry=paths)" +"very long text was cut to 1,048,576 characters" = "el texto muy largo se recortó a 1.048.576 caracteres" +"very long vector paths were skipped" = "se omitieron los trazados vectoriales muy largos" +"video actions are not supported and were skipped" = "las acciones de vídeo no son compatibles y se omitieron" +"video triggers are not supported and were skipped" = "los desencadenantes de vídeo no son compatibles y se omitieron" "yesterday" = "ayer" "{instances} in {files}" = "{instances} en {files}" "{language}: draft translation, waiting for a native speaker's review." = "{language}: traducción en borrador, pendiente de revisión por un hablante nativo." @@ -1571,6 +1642,7 @@ status = "draft — needs native review" "{n} matches" = { one = "{n} coincidencia", other = "{n} coincidencias" } "{n} minutes ago" = { one = "hace {n} minuto", other = "hace {n} minutos" } "{n} months ago" = { one = "hace {n} mes", other = "hace {n} meses" } +"{n} more notes omitted from this report" = { one = "{n} nota más omitida de este informe", other = "{n} notas más omitidas de este informe" } "{n} of {total}" = "{n} de {total}" "{n} people joined" = { one = "{n} persona se ha unido", other = "{n} personas se han unido" } "{n} preferred components" = { one = "{n} componente preferido", other = "{n} componentes preferidos" } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 236f8e0d..2990cd3f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -139,6 +139,7 @@ pub fn keys(app: &mut FigApp, ctx: &Context) { let open = u.color_profile.is_some() || u.find.is_some() || u.batch_rename.is_some() + || u.import_report.is_some() || u.confirm_close || u.export_preview || u.shortcuts_panel @@ -147,7 +148,7 @@ pub fn keys(app: &mut FigApp, ctx: &Context) { if !open || !ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { return; } - if u.color_profile.take().is_some() || u.find.take().is_some() || u.batch_rename.take().is_some() { + if u.color_profile.take().is_some() || u.find.take().is_some() || u.batch_rename.take().is_some() || u.import_report.take().is_some() { return; } for flag in [&mut u.confirm_close, &mut u.export_preview, &mut u.shortcuts_panel, &mut u.preferences, &mut u.about] { @@ -160,6 +161,7 @@ pub fn keys(app: &mut FigApp, ctx: &Context) { pub fn show(app: &mut FigApp, ctx: &Context) { // Dropped files waiting to open: their card, then (once it has shown) the files. crate::pending_open::pending_opens(app, ctx); + crate::import_report::show(app, ctx); let t = Tokens::get(ctx); if app.ui.about { let mut open = true; diff --git a/crates/ui-egui/src/home_recents.rs b/crates/ui-egui/src/home_recents.rs index d633f776..6d9794c2 100644 --- a/crates/ui-egui/src/home_recents.rs +++ b/crates/ui-egui/src/home_recents.rs @@ -568,7 +568,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { action = action.or(chosen); match action { Some(Action::Open(path)) => { - if app.run("file.open", json!({"path": path})).is_some() { + if app.run_user("file.open", json!({"path": path})).is_some() { app.ui.home = false; } } diff --git a/crates/ui-egui/src/import_report.rs b/crates/ui-egui/src/import_report.rs new file mode 100644 index 00000000..b756e871 --- /dev/null +++ b/crates/ui-egui/src/import_report.rs @@ -0,0 +1,246 @@ +//! The import report: what a successful `.fig`, REST JSON or SVG import left out or changed, shown +//! in a dialog window after the file opens, so the notes don't vanish with a toast. + +use egui::RichText; +use serde_json::{Value, json}; + +use crate::FigApp; +use crate::i18n::{tr, tr_noop, trn}; +use crate::theme::{Tokens, regular}; + +/// A report stays small even when a damaged input produces many or very long notes. +pub const MAX_NOTES: usize = 500; +pub const MAX_NOTE_CHARS: usize = 2_000; + +/// The fixed notes of the `.fig` and REST importers, translated in the report. `.fig` imports name +/// "the file" where REST imports name the JSON they read. Other notes (SVG's, say) are shown as they are. +const IMPORT_NOTES: &[&str] = &[ + tr_noop("conditional prototype actions nested too deeply were skipped"), + tr_noop("conditional prototype actions with conditions that can't be evaluated were skipped"), + tr_noop("connector labels were skipped"), + tr_noop("connectors whose ends can't be placed are empty"), + tr_noop("damaged vector geometry was skipped"), + tr_noop("damaged vector networks import as outlines"), + tr_noop("document children other than pages were skipped"), + tr_noop("elbowed and curved connectors without a saved path import as straight lines"), + tr_noop("entries without a node type were skipped"), + tr_noop("filled areas of a vector with their own fill use the layer's fill"), + tr_noop("gamepad triggers without exactly one known controller button were skipped"), + tr_noop("grid tracks of fixed or content size import as equal tracks"), + tr_noop("instance swap properties whose default component isn't in the file were skipped"), + tr_noop("instances inside their own main component were imported as frames"), + tr_noop("instances of components that aren't in the file show no layers"), + tr_noop("instances of components that aren't in the file were imported as frames"), + tr_noop("instances that would nest a component inside itself were imported as frames"), + tr_noop("instances without a main component were imported as empty frames"), + tr_noop("invalid or oversized vector networks import as outlines"), + tr_noop("invalid or oversized vector path data was skipped"), + tr_noop("key triggers without a supported key were skipped"), + tr_noop("key up triggers import as key presses"), + tr_noop("layers without a position were placed at their parent's origin"), + tr_noop("layers without a type were skipped"), + tr_noop("points with their own stroke join use the layer's"), + tr_noop("prototype actions of unknown types were skipped"), + tr_noop("prototype actions without a destination in the file were skipped"), + tr_noop("prototype expressions on variables that aren't in the variables JSON were skipped"), + tr_noop("prototype expressions with unsupported functions were skipped"), + tr_noop("prototype interactions without supported actions were skipped"), + tr_noop("prototype navigations of unknown types were skipped"), + tr_noop("prototype transitions of unknown types play instantly"), + tr_noop("prototype triggers of unknown types were skipped"), + tr_noop("set-variable actions on variables that aren't in the variables JSON were skipped"), + tr_noop("set-variable actions with values that can't be evaluated were skipped"), + tr_noop("set-variable actions with values that don't fit the variable were skipped"), + tr_noop("set-variable actions without a value were skipped"), + tr_noop("set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped"), + tr_noop("stars and polygons without point counts or geometry use the default point count"), + tr_noop("text style overrides refer to missing table entries"), + tr_noop("the file has no pages"), + tr_noop("the variables JSON has no `variableCollections` (expected the local variables response) and was ignored"), + tr_noop("unknown component property types were skipped"), + tr_noop("variable aliases to variables that aren't in the variables JSON (or of another type) were skipped"), + tr_noop("variable bindings to properties the layer doesn't have were skipped"), + tr_noop("variable bindings to variables that aren't in the variables JSON (library variables) were skipped"), + tr_noop( + "variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)", + ), + tr_noop("variable bindings whose variable type doesn't fit the property were skipped"), + tr_noop("variable collections without modes were skipped"), + tr_noop("variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped"), + tr_noop("variable modes of collections that aren't in the file were skipped"), + tr_noop("variable modes of collections that aren't in the variables JSON were skipped"), + tr_noop("variable values that don't match their type (or composed colours) were skipped"), + tr_noop("variables of unknown types were skipped"), + tr_noop("variables whose collection isn't in the variables JSON were skipped"), + tr_noop("variables without readable values were skipped"), + tr_noop("vectors without geometry are empty"), + tr_noop("vectors without geometry are empty (export the file JSON with geometry=paths)"), + tr_noop("very long text was cut to 1,048,576 characters"), + tr_noop("very long vector paths were skipped"), + tr_noop("video actions are not supported and were skipped"), + tr_noop("video triggers are not supported and were skipped"), +]; + +#[derive(Clone, Debug)] +pub struct Report { + pub name: String, + pub pages: Option, + pub layers: Option, + pub images: Option, + /// The pages of an imported file were added to the open document. + pub appended: bool, + /// An SVG was placed on the current page of the open document. + pub placed: bool, + /// The document the report belongs to: it goes when that document closes. + pub uid: u64, + pub notes: Vec, + pub note_count: usize, +} + +impl Report { + /// Only successful `file.open` and `file.import` results with import `warnings` create a report; + /// a native file's `repairs` are shown by the engine's own message, and an SVG opened without + /// notes has nothing to report. `params` are the command's, for the file's name. + pub fn from_result(command: &str, params: &Value, result: &Value) -> Option { + if !matches!(command, "file.open" | "file.openRecent" | "file.import") { + return None; + } + let warnings = result.get("warnings").and_then(Value::as_array)?; + let file_name = params.get("path").and_then(Value::as_str).and_then(|p| std::path::Path::new(p).file_name()).and_then(|n| n.to_str()); + if command != "file.import" && warnings.is_empty() && file_name.is_some_and(|n| n.to_ascii_lowercase().ends_with(".svg")) { + return None; + } + // `file.import` of an SVG places a frame on the current page (no `pages`): its notes + // are reported, but no pages were appended. + let appended = command == "file.import" && result.get("pages").is_some(); + if command == "file.import" && !appended && warnings.is_empty() { + return None; + } + let summary = result.get("summary"); + let count = |key: &str| summary.and_then(|s| s.get(key)).and_then(Value::as_u64); + let name = summary + .and_then(|s| s.get("name")) + .and_then(Value::as_str) + .or(file_name) + .or_else(|| params.get("name").and_then(Value::as_str)) + .unwrap_or(tr("Imported document")); + let mut report = Self { + name: name.chars().take(512).collect(), + pages: count("pages"), + layers: count("layers"), + images: count("images"), + appended, + placed: command == "file.import" && !appended, + uid: 0, + notes: Vec::new(), + note_count: 0, + }; + for note in warnings.iter().filter_map(Value::as_str) { + report.add_note(note); + } + Some(report) + } + + fn add_note(&mut self, note: &str) { + self.note_count = self.note_count.saturating_add(1); + if self.notes.len() < MAX_NOTES { + let mut text: String = note.chars().take(MAX_NOTE_CHARS).collect(); + if note.chars().nth(MAX_NOTE_CHARS).is_some() { + text.push('…'); + } + self.notes.push(text); + } + } + + pub fn inspect(&self) -> Value { + json!({"name": self.name, "pages": self.pages, "layers": self.layers, "images": self.images, + "appended": self.appended, "placed": self.placed, "warnings": self.notes, "warningCount": self.note_count}) + } +} + +/// `note` in the UI language when it is a known import note; a repeat count (` (×N)`) is kept. The +/// stored notes are lower-case fragments; a known note is shown as a sentence, so its first letter +/// is capitalised here (unknown notes are left as they are). +pub(crate) fn note_text(note: &str) -> String { + let translated = |base: &str| { + if !IMPORT_NOTES.contains(&base) { + return base.to_owned(); + } + let text = tr(base); + let mut chars = text.chars(); + chars.next().map_or_else(String::new, |first| first.to_uppercase().chain(chars).collect()) + }; + match note.rsplit_once(" (×") { + Some((base, count)) if count.strip_suffix(')').is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) => { + format!("{} (×{count}", translated(base)) + } + _ => translated(note), + } +} + +/// Show the report for a file the user opened or imported (menus, drops, pastes, recents, the +/// system's open event); commands from the control channel, MCP or scripts never call this. +pub fn capture(app: &mut FigApp, command: &str, params: &Value, result: &Value) { + if let Some(mut report) = Report::from_result(command, params, result) { + report.uid = app.session.ds().map_or(0, |d| d.uid); + app.ui.import_report = Some(report); + } +} + +pub fn show(app: &mut FigApp, ctx: &egui::Context) { + // The report goes with its document. + if app.ui.import_report.as_ref().is_some_and(|r| !app.session.docs.iter().any(|d| d.uid == r.uid)) { + app.ui.import_report = None; + } + let Some(report) = &app.ui.import_report else { return }; + let t = Tokens::get(ctx); + let mut open = true; + let mut close_clicked = false; + crate::dialogs::window(tr("Import report"), ctx).open(&mut open).show(ctx, |ui| { + ui.set_width(420.0); + ui.label(RichText::new(&report.name).strong()); + ui.label(if report.appended { + tr("Pages added to the document") + } else if report.placed { + tr("Placed on the current page") + } else { + tr("Opened as a new document") + }); + let counts: Vec = [(tr("Pages"), report.pages), (tr("Layers"), report.layers), (tr("Images"), report.images)] + .into_iter() + .filter_map(|(label, count)| count.map(|n| format!("{label}: {n}"))) + .collect(); + if !counts.is_empty() { + ui.label(counts.join(" · ")); + } + ui.add_space(8.0); + if report.notes.is_empty() { + ui.label(tr("No compatibility warnings reported")); + } else { + ui.label(RichText::new(tr("Compatibility notes")).strong()); + ui.label( + RichText::new(tr( + "FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged.", + )) + .color(t.text_secondary) + .font(regular(11.0)), + ); + ui.add_space(4.0); + egui::ScrollArea::vertical().id_salt("import_report_notes").max_height(260.0).show(ui, |ui| { + for note in &report.notes { + ui.label(note_text(note)); + ui.add_space(4.0); + } + }); + } + let omitted = report.note_count.saturating_sub(report.notes.len()); + if omitted > 0 { + ui.label(trn("{n} more notes omitted from this report", omitted, &[])); + } + ui.add_space(12.0); + close_clicked = crate::widgets::button(ui, tr("Close"), true, true).clicked(); + }); + if !open || close_clicked { + app.ui.import_report = None; + } +} diff --git a/crates/ui-egui/src/import_report_tests.rs b/crates/ui-egui/src/import_report_tests.rs new file mode 100644 index 00000000..cc493e4b --- /dev/null +++ b/crates/ui-egui/src/import_report_tests.rs @@ -0,0 +1,174 @@ +use figcraft_engine::Session; +use serde_json::json; + +use crate::import_report::{MAX_NOTE_CHARS, MAX_NOTES, Report, note_text}; +use crate::{FigApp, NoServices}; + +#[test] +fn import_results_with_warnings_become_reports_with_counts() { + let result = + json!({"warnings": ["layers without a type were skipped"], "summary": {"name": "Synthetic design", "pages": 2, "layers": 12, "images": 3}}); + let report = Report::from_result("file.import", &json!({}), &result).unwrap(); + assert_eq!(report.name, "Synthetic design"); + assert_eq!((report.pages, report.layers, report.images), (Some(2), Some(12), Some(3))); + assert_eq!(report.notes, ["layers without a type were skipped"]); + assert_eq!(report.note_count, 1); +} + +#[test] +fn only_import_commands_with_warnings_create_reports() { + // A warning-free `.fig` or REST open still has a summary to show. + let report = Report::from_result("file.open", &json!({}), &json!({"warnings": [], "summary": {"name": "Local", "pages": 1}})).unwrap(); + assert!(!report.appended && report.notes.is_empty()); + // A native file's repairs have their own message; an SVG without notes has no `warnings`. + assert!(Report::from_result("file.open", &json!({}), &json!({"repairs": ["Repaired parent"]})).is_none()); + assert!(Report::from_result("file.open", &json!({}), &json!({"index": 0})).is_none()); + assert!(Report::from_result("file.export", &json!({}), &json!({"warnings": ["Export warning"]})).is_none()); + assert!(Report::from_result("shape.rectangle", &json!({}), &json!({})).is_none()); + // File ▸ Open recent opens by path and reports like File ▸ Open. + let recent = json!({"warnings": ["layers without a type were skipped"], "summary": {"name": "Recent", "pages": 1}}); + assert!(Report::from_result("file.openRecent", &json!({"path": "Recent.json"}), &recent).is_some()); + assert!(Report::from_result("file.openRecent", &json!({"path": "Clean.svg"}), &json!({"warnings": []})).is_none()); +} + +#[test] +fn svg_placements_are_not_reported_as_appended_pages() { + assert!(Report::from_result("file.import", &json!({}), &json!({"id": 7})).is_none()); + let report = Report::from_result("file.import", &json!({}), &json!({"id": 7, "warnings": ["Filter skipped"]})).unwrap(); + assert!(!report.appended); + assert!(Report::from_result("file.import", &json!({}), &json!({"pages": [1], "summary": {"pages": 1}, "warnings": []})).unwrap().appended); +} + +#[test] +fn report_retention_is_bounded_and_truncates_only_at_character_boundaries() { + let long_note = "🦎".repeat(MAX_NOTE_CHARS + 20); + let notes: Vec<_> = std::iter::repeat_n(long_note, MAX_NOTES + 37).collect(); + let report = Report::from_result("file.open", &json!({}), &json!({"warnings": notes})).unwrap(); + assert_eq!(report.note_count, MAX_NOTES + 37); + assert_eq!(report.notes.len(), MAX_NOTES); + assert!(report.notes.iter().all(|note| note.chars().count() == MAX_NOTE_CHARS + 1 && note.ends_with('…'))); +} + +#[test] +fn note_text_keeps_the_repeat_suffix_and_unknown_notes() { + let base = "instances of components that aren't in the file were imported as frames"; + let repeated = note_text(&format!("{base} (×122)")); + assert_eq!(repeated, "Instances of components that aren't in the file were imported as frames (×122)"); + assert_eq!(note_text("some unknown note"), "some unknown note"); + assert_eq!(note_text("weird (×x)"), "weird (×x)"); + assert_eq!(note_text("weird (×12) tail"), "weird (×12) tail"); +} + +#[test] +fn opening_a_rest_json_file_reports_what_was_skipped() { + let path = std::env::temp_dir().join(format!("figcraft-import-report-{}.json", std::process::id())); + let file = json!({"document": {"id": "0:0", "name": "Synthetic", "type": "DOCUMENT", "children": [ + {"id": "0:1", "name": "Page 1", "type": "CANVAS", "children": [ + {"id": "1:1", "name": "Card", "type": "FRAME", "size": {"x": 200, "y": 100}, "relativeTransform": [[1, 0, 10], [0, 1, 20]], + "children": [{"id": "1:2", "name": "No type"}]}]}]}}); + std::fs::write(&path, file.to_string()).unwrap(); + let mut app = FigApp::new(Session::new(), Box::new(NoServices)); + // A programmatic open (control channel, MCP) never opens the window. + assert!(app.run("file.open", json!({"path": path.to_string_lossy()})).is_some()); + assert!(app.ui.import_report.is_none()); + let opened = app.run_user("file.open", json!({"path": path.to_string_lossy()})); + std::fs::remove_file(&path).unwrap(); + assert!(opened.is_some()); + let report = app.ui.import_report.as_ref().expect("a report for the REST import"); + assert!(!report.appended); + assert_eq!(report.pages, Some(1)); + assert!(report.layers.is_some_and(|n| n >= 1)); + assert!(report.notes.iter().any(|n| n == "layers without a type were skipped"), "{:?}", report.notes); + assert_eq!(app.inspect()["importReport"]["warningCount"], report.note_count); +} + +#[test] +fn escape_closes_the_import_report_like_other_simple_dialogs() { + let mut u = crate::layers_drag_tests::Ui::new(Session::new()); + let mut report = Report::from_result("file.open", &json!({}), &json!({"warnings": ["Synthetic note"], "summary": {"name": "Synthetic design"}})); + report.as_mut().unwrap().uid = u.app.session.ds().unwrap().uid; + u.app.ui.import_report = report; + u.frame(vec![]); + let escape = + vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }]; + u.frame(escape); + assert!(u.app.ui.import_report.is_none(), "Escape closes the report"); +} + +fn shown_texts(shapes: Vec) -> Vec { + fn walk(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let mut out = Vec::new(); + shapes.iter().for_each(|c| walk(&c.shape, &mut out)); + out +} + +#[test] +fn a_clean_svg_open_has_no_report_and_a_placed_svg_says_where_it_went() { + let svg_open = Report::from_result("file.open", &json!({"path": "icons/Logo.SVG"}), &json!({"warnings": []})); + assert!(svg_open.is_none(), "a clean SVG opened with file.open has nothing to report"); + let noted = Report::from_result("file.open", &json!({"path": "Logo.svg"}), &json!({"warnings": ["Filter skipped"]})).unwrap(); + assert_eq!(noted.name, "Logo.svg"); + + let placed = Report::from_result("file.import", &json!({"path": "dir/Logo.svg"}), &json!({"id": 7, "warnings": ["Filter skipped"]})).unwrap(); + assert!(placed.placed && !placed.appended); + assert_eq!(placed.name, "Logo.svg"); + let mut u = crate::layers_drag_tests::Ui::new(Session::new()); + let mut placed = placed; + placed.uid = u.app.session.ds().unwrap().uid; + u.app.ui.import_report = Some(placed); + let texts = shown_texts({ + u.frame(vec![]); + u.frame_output(vec![]).shapes + }); + assert!(texts.iter().any(|t| t == "Placed on the current page"), "{texts:?}"); + assert!(!texts.iter().any(|t| t == "Opened as a new document"), "{texts:?}"); + assert!(texts.iter().any(|t| t == "Logo.svg"), "{texts:?}"); +} + +#[test] +fn the_report_goes_when_its_document_closes_and_counts_omitted_notes_in_the_singular() { + let mut u = crate::layers_drag_tests::Ui::new(Session::new()); + let mut report = Report::from_result("file.open", &json!({}), &json!({"warnings": ["One note"], "summary": {"name": "Synthetic"}})).unwrap(); + report.uid = u.app.session.ds().unwrap().uid; + report.note_count = 2; + u.app.ui.import_report = Some(report); + let texts = shown_texts({ + u.frame(vec![]); + u.frame_output(vec![]).shapes + }); + assert!(texts.iter().any(|t| t == "1 more note omitted from this report"), "{texts:?}"); + + u.app.session.execute("file.new", &json!({"name": "Other"})).unwrap(); + u.frame(vec![]); + assert!(u.app.ui.import_report.is_some(), "another tab was added; the report's document is still open"); + u.app.session.execute("file.close", &json!({"index": 0, "force": true})).unwrap(); + u.frame(vec![]); + assert!(u.app.ui.import_report.is_none(), "the report left with its document"); +} + +#[test] +fn a_dropped_file_shows_its_report_once_it_opens() { + let path = std::env::temp_dir().join(format!("figcraft-import-report-drop-{}.json", std::process::id())); + let file = json!({"document": {"id": "0:0", "name": "Dropped", "type": "DOCUMENT", "children": [ + {"id": "0:1", "name": "Page 1", "type": "CANVAS", "children": [{"id": "1:1", "name": "No type"}]}]}}); + std::fs::write(&path, file.to_string()).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(Session::new()); + u.app.ui.pending_opens.push(crate::pending_open::PendingOpen { + label: "Dropped.json".into(), + params: json!({"path": path.to_string_lossy()}), + command: "file.open", + target: None, + }); + for _ in 0..6 { + u.frame(vec![]); + } + std::fs::remove_file(&path).unwrap(); + let report = u.app.ui.import_report.as_ref().expect("the dropped file's report"); + assert_eq!(report.uid, u.app.session.ds().unwrap().uid); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20a8a77c..3d64ecae 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -32,6 +32,7 @@ pub mod home; pub mod home_recents; pub mod i18n; pub mod icons; +pub mod import_report; pub mod layers; pub mod layout_panel; pub mod list_drag; @@ -108,6 +109,8 @@ mod home_tests; #[cfg(test)] mod idle_tests; #[cfg(test)] +mod import_report_tests; +#[cfg(test)] mod layers_behaviour_tests; #[cfg(test)] mod layers_drag_tests; @@ -456,6 +459,8 @@ pub struct UiState { /// ([`pending_open::pending_opens`]), and the frames it has been drawn. pub pending_opens: Vec, pub pending_open_frames: u8, + /// The latest successful file import's summary and compatibility notes. + pub import_report: Option, pub screenshot_requests: Vec<(u64, String)>, pub frame_ms: f64, pub render_ms: f64, @@ -564,6 +569,7 @@ impl Default for UiState { last_error: None, pending_opens: Vec::new(), pending_open_frames: 0, + import_report: None, screenshot_requests: Vec::new(), frame_ms: 0.0, render_ms: 0.0, @@ -676,6 +682,16 @@ impl FigApp { r.map_err(|e| self.failed(id, &e)).ok() } + /// [`run`](Self::run) for something the user did (a dialog, a drop, a paste, a recent file): + /// a file it imports shows its import report. Programmatic calls use `run` and never open one. + pub fn run_user(&mut self, id: &str, params: Value) -> Option { + let result = self.run(id, params.clone()); + if let Some(result) = &result { + import_report::capture(self, id, ¶ms, result); + } + result + } + /// Show command `id`'s error. fn failed(&mut self, id: &str, e: &figcraft_engine::EngineError) { let msg = e.to_string(); @@ -966,7 +982,7 @@ impl FigApp { false, |app, p| { let ps = p.to_string_lossy().to_string(); - if app.run("file.open", json!({"path": ps})).is_some() { + if app.run_user("file.open", json!({"path": ps})).is_some() { app.ui.home = false; } }, @@ -982,7 +998,7 @@ impl FigApp { let title = if place { i18n::tr("Place image") } else { i18n::tr("Import") }; self.pick_open(title, exts, move |app, p| { let ps = p.to_string_lossy().to_string(); - app.run("file.import", json!({"path": ps})); + app.run_user("file.import", json!({"path": ps})); }); } @@ -1527,6 +1543,7 @@ impl FigApp { "panels": {"left": self.ui.left_width, "right": self.ui.right_width, "leftCollapsed": self.ui.left_collapsed}, "status": self.session.status(), "lastError": self.ui.last_error, + "importReport": self.ui.import_report.as_ref().map(import_report::Report::inspect), }); state["pendingOpens"] = json!(self.ui.pending_opens.len()); state diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b9d3299a..2c365d9f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -54,7 +54,7 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>) { } else if figcraft_engine::looks_like_svg(&text) { use base64::Engine as _; let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); - app.run("file.import", json!({"data": b64, "name": "Pasted SVG.svg", "x": cx, "y": cy, "center": true})); + app.run_user("file.import", json!({"data": b64, "name": "Pasted SVG.svg", "x": cx, "y": cy, "center": true})); } else { app.run("text.create", json!({"text": text, "x": cx, "y": cy})); } @@ -234,24 +234,25 @@ fn recent_menu(app: &mut FigApp, ui: &mut Ui, prefix: &str, key: &str, label: &s let picked = submenu(ui, prefix, &format!("{prefix}/{key}"), label, t, |ui| { let recents = &app.session.prefs.recent_files; let mut picked = None; - for (i, p) in recents.iter().enumerate() { + for p in recents.iter() { let path = std::path::Path::new(p); let name = path.file_name().map_or_else(|| p.clone(), |f| f.to_string_lossy().into_owned()); let exists = path.exists(); if item(ui, &name, "", exists, false, t) && exists { - picked = Some(json!({"index": i})); + picked = Some(("file.openRecent", json!({"path": p}))); } } if !recents.is_empty() { separator(ui); } if item(ui, tr("Clear recent"), "", !recents.is_empty(), false, t) && !recents.is_empty() { - picked = Some(json!({"clear": true})); + picked = Some(("file.openRecent", json!({"clear": true}))); } picked }); - let Some(params) = picked else { return false }; - if app.run("file.openRecent", params).is_some_and(|r| r.get("index").is_some()) { + let Some((command, params)) = picked else { return false }; + // Through the user path, so an import report shows; `file.openRecent` also drops a file that is gone. + if app.run_user(command, params).is_some_and(|r| r.get("index").is_some()) { app.ui.home = false; } true diff --git a/crates/ui-egui/src/pending_open.rs b/crates/ui-egui/src/pending_open.rs index 63c213ff..9d19b942 100644 --- a/crates/ui-egui/src/pending_open.rs +++ b/crates/ui-egui/src/pending_open.rs @@ -29,7 +29,7 @@ pub struct ImportTarget { fn run_pending(app: &mut FigApp, pending: PendingOpen) -> Option { let target = match pending.target { Some(target) => target, - None if pending.command != "file.import" => return app.run(pending.command, pending.params), + None if pending.command != "file.import" => return app.run_user(pending.command, pending.params), None => { cancel(app, tr("The destination document is unavailable. Nothing was imported.").to_string()); return None; @@ -49,7 +49,7 @@ fn run_pending(app: &mut FigApp, pending: PendingOpen) -> Option { let previous_selection = app.session.docs[index].selection.clone(); app.session.active = index; app.session.docs[index].page = target.page; - let result = app.run(pending.command, pending.params); + let result = app.run_user(pending.command, pending.params); if previous_page != target.page && let Some(doc) = app.session.docs.iter_mut().find(|doc| doc.uid == target.uid) && doc.doc.pages.contains(&previous_page) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 397aed5b..80e8e488 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -117,7 +117,13 @@ zoom preview or the refined image (`canvas.quality`: `preview` or `refined`) and asking for a render to its preview and its refined image being handed to egui as the canvas texture (`canvas.previewLatencyMs`, `canvas.refineLatencyMs`, on a monotonic clock, rendering included in the browser too; the GPU upload and presenting the frame come after; `null` until -there was one). While a prototype plays, `presenter` +there was one). `importReport` holds the Import report window's content (`{name, pages, layers, images, appended, placed, warnings, warningCount}`, +`null` while none is open). The window opens only for a file the user opened or imported in the app +(File ▸ Open or Import, Open recent, a recent file on the home screen, a drop, a paste, the system's open event). +Emulated user input counts as the user's action: `ui.input` and `ui.key` (for example a Cmd+V paste or a click +on a menu item) can open it. Commands sent through this channel with `engine.execute`, MCP, `window.new {path}` +and files named on the command line never open it. +`file.open` and `file.import` return the counts as `summary`. While a prototype plays, `presenter` (full window) and `preview` (the preview pane) hold the player's state: the `current` frame and its `name`, `start`, `history`, open `overlays`, how many delayed mouse triggers are `waiting`, the screen's `scroll` offset and `scrollRange`, the `device` (and whether it is turned to `landscape`, its `deviceColor`) and the `viewport` of the frame diff --git a/docs/mcp.md b/docs/mcp.md index 4efee72a..9cc428b0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -227,7 +227,7 @@ Or in a project's `.mcp.json`: | `select {ids}` | Replace the selection (`[]` deselects). | | `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}` | png, jpg, svg, webp, pdf, or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps ``, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `.`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). | | `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. | -| `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` (best effort, see below), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. | +| `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` (best effort, see below), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. `open_file` returns `opened` (for a file opened anew, a `summary` of `name`, `pages`, `layers` and `images`, and `warnings` or `repairs`; a file that was already open just gives its `index`) and the document's `document` outline. | | `undo` / `redo` | Step through history. | | `pointer {kind, x, y, to?, steps?, tool?, shift, alt, cmd, pressure?, pressureTo?}` | Mouse gestures in page coordinates with the current tool: `click`, `double`, `drag`, `down`, `move`, `up`, `hover`. A dragged layer drops into the frame or auto layout slot under the pointer, except one inside a group, a boolean group or an instance (outside its slots), which only moves. `pressure` (0–1) is pen pressure; a drag goes from `pressure` to `pressureTo` (see Brushes and pen pressure). | | `key {key, shift, alt, cmd}` | A shortcut or key as if typed on the canvas (every default shortcut, tool keys, Escape, Delete, arrows, number keys for opacity, arrows that pan the view when nothing is selected; while editing text, the text shortcuts and then text editing keys). Keys are names like `A`, `0`, `Enter`, `ArrowLeft`, `=`, `/`, or egui's (`Plus`, `Questionmark`); `cmd` is Cmd on macOS and Ctrl elsewhere. | From 5d1c24d0615696e590ba57fc7076776f773b1592 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 02:19:42 +0200 Subject: [PATCH 007/118] Contributors: add my name for the About window --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index bc8cf71b..ba7977a8 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -44,3 +44,8 @@ sync_github_name = true [people.volkovskey] real_name = "Andrii Volkov" + +[people.marcoarnulfo] +real_name = "Marco Arnulfo" +display_name = "Marco Arnulfo" +sync_github_name = false From d331db00aa5b09f1c97e9411fc6b1476d54ed82b Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 01:31:56 +0200 Subject: [PATCH 008/118] M1: keep explicit instance identity and cached library components in .fig import A .fig import now links the layers of an instance to the layers of the component they came from by the ids the file stores, instead of leaving it to the engine to guess from names and places. Renamed slots and layers, siblings that share a name and swapped mains keep their source link and the override they carry. Components that live only on the file's hidden internal pages (cached copies of library components) are kept in the hidden library store when a visible layer uses them, so their instances stay instances. The read-only guard for library copies now compares the copies themselves rather than the dirty set, which an import leaves full until the first edit. Rebasing an instance (after detaching an outer instance or restoring a main) follows the source chain inside the right enclosing instance, so contextual links survive it. The last change of a GUID wins and a removed change deletes it. Ordinary file JSON never reads the private provenance. Importing such a file into an open document, or pasting its layers into another one, copies the cached components along (import) or leaves them behind as a missing main (paste), so no link points at a layer of the target. --- crates/engine/src/cmd/file.rs | 84 ++ crates/engine/src/components.rs | 226 ++++- .../src/components_rebase_source_tests.rs | 244 +++++ crates/engine/src/fig_import_tests.rs | 40 + crates/engine/src/library.rs | 13 +- crates/engine/src/library_tests.rs | 46 + crates/format/src/fig/map.rs | 225 ++++- crates/format/src/fig/mod.rs | 4 +- .../format/src/fig/source_identity_tests.rs | 938 ++++++++++++++++++ crates/format/src/rest/fig_sources.rs | 316 ++++++ crates/format/src/rest/mod.rs | 37 +- 11 files changed, 2138 insertions(+), 35 deletions(-) create mode 100644 crates/engine/src/components_rebase_source_tests.rs create mode 100644 crates/format/src/fig/source_identity_tests.rs create mode 100644 crates/format/src/rest/fig_sources.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 5e27c572..f50c5edc 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -1272,6 +1272,19 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result> { map.insert(id, doc.alloc_id()); } } + // Library copies a .fig import keeps (cached definitions) have no library record to follow: + // the layers of its store come along into the store of `doc`. Recorded copies are left to + // `merge_store`, which shares them with copies `doc` already has. + let cached: Vec = other + .library_store() + .and_then(|s| other.node(s)) + .map(|s| s.children.iter().copied().filter(|root| other.libraries.import_of(*root).is_none()).collect()) + .unwrap_or_default(); + for root in &cached { + for id in other.subtree(*root) { + map.insert(id, doc.alloc_id()); + } + } let mut new_pages = Vec::new(); for pg in &other.pages { let Some(pn) = other.node(*pg) else { continue }; @@ -1289,6 +1302,13 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result> { } new_pages.push(np); } + if !cached.is_empty() { + let store = doc.ensure_library_store(); + for root in &cached { + let nodes: Vec = other.subtree(*root).into_iter().filter_map(|i| other.node(i).cloned()).collect(); + doc.paste_nodes(&nodes, &map, *root, store, None)?; + } + } for new in map.values() { let src = doc.node(*new).and_then(|n| n.source).and_then(|s| map.get(&s).copied()); if let (Some(src), Some(n)) = (src, doc.node_mut(*new)) { @@ -1354,6 +1374,70 @@ mod rest_import_tests { doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap() } + /// What a .fig import with a cached library component leaves: the main in the hidden store, a + /// visible instance of it and a layer linked to the main's layer. + fn cached_import() -> Document { + let mut doc = Document::new("Cached"); + let page = doc.first_page().unwrap(); + let store = doc.ensure_library_store(); + let main = doc + .create(store, NodeKind::Component { description: String::new(), props: Default::default() }, "Cached main", 0.0, 0.0, 20.0, 20.0) + .unwrap(); + let body = doc.create(main, NodeKind::Rectangle, "Body", 0.0, 0.0, 20.0, 20.0).unwrap(); + let inst = + doc.create(page, NodeKind::Instance { component: main, props: Default::default() }, "Cached instance", 0.0, 0.0, 20.0, 20.0).unwrap(); + let mut copy = doc.node(body).unwrap().clone(); + copy.id = doc.alloc_id(); + copy.parent = None; + copy.source = Some(body); + let copy_id = copy.id; + doc.insert(copy, inst, None).unwrap(); + assert!(doc.is_remote(main) && doc.node(copy_id).unwrap().source == Some(body)); + doc + } + + /// Pasting layers that use a cached main into another document leaves the main behind, as it + /// does for other mains that are not published: no link points at a layer of the target. + #[test] + fn pasting_instances_of_cached_components_leaves_no_dangling_links() { + let src = cached_import(); + let mut doc = Document::new("Target"); + let page = doc.first_page().unwrap(); + for i in 0..8 { + doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap(); + } + let inst = src.nodes().find(|n| n.name == "Cached instance").unwrap().id; + let ids = src.subtree(inst); + let map: std::collections::BTreeMap = ids.iter().map(|i| (*i, doc.alloc_id())).collect(); + let nodes: Vec = ids.iter().filter_map(|i| src.node(*i).cloned()).collect(); + doc.paste_nodes(&nodes, &map, inst, page, None).unwrap(); + crate::library::adopt_pasted(&mut doc, &src, None, &map).unwrap(); + let pasted = doc.node(map[&inst]).unwrap(); + let NodeKind::Instance { component, .. } = pasted.kind else { panic!() }; + assert!(!doc.contains(component), "the main stays behind: {component:?}"); + assert!(doc.node(pasted.children[0]).unwrap().source.is_none_or(|s| !doc.contains(s))); + } + + #[test] + fn appending_pages_with_cached_library_components_brings_the_components() { + let other = cached_import(); + let mut doc = Document::new("Target"); + let page = doc.first_page().unwrap(); + // Ids of the target that would collide with the other document's store. + for i in 0..8 { + doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap(); + } + append_pages(&mut doc, &other).unwrap(); + assert!(doc.validate().is_empty(), "{:?}", doc.validate()); + assert!(crate::components::mirror_issues(&doc).is_empty(), "{:?}", crate::components::mirror_issues(&doc)); + let inst = doc.nodes().find(|n| n.name == "Cached instance").unwrap(); + let NodeKind::Instance { component, .. } = inst.kind else { panic!() }; + assert_eq!(doc.node(component).unwrap().name, "Cached main"); + assert!(doc.is_remote(component), "the main comes along into the store"); + let layer = doc.node(inst.children[0]).unwrap(); + assert_eq!(layer.source, doc.node(component).unwrap().children.first().copied()); + } + #[test] fn rest_json_loads_with_linked_instances() { let bytes = serde_json::to_vec(&kit()).unwrap(); diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index da0b2d13..30f21b8c 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -1710,6 +1710,165 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti None } +/// Bounded source and parent lookups while a rebase reads an unchanged document. +struct RebaseSources<'a> { + doc: &'a Document, + chains: BTreeMap>>, + parents: BTreeMap<(NodeId, NodeId), Option>, + main_changes: BTreeMap, +} + +impl<'a> RebaseSources<'a> { + const PARENTS: usize = 512; + const CACHE: usize = 8192; + + fn new(doc: &'a Document) -> Self { + Self { doc, chains: BTreeMap::new(), parents: BTreeMap::new(), main_changes: BTreeMap::new() } + } + + fn chain(&mut self, source: NodeId) -> Option> { + if let Some(chain) = self.chains.get(&source) { + return chain.clone(); + } + let read = || { + let mut chain = Vec::new(); + let mut seen = BTreeSet::new(); + let mut current = Some(source); + for _ in 0..MAX_DEPTH { + let Some(id) = current else { return Some(chain) }; + if !seen.insert(id) { + return None; + } + let n = self.doc.node(id)?; + chain.push(id); + current = n.source; + } + current.is_none().then_some(chain) + }; + let chain = read(); + if self.chains.len() < Self::CACHE { + self.chains.insert(source, chain.clone()); + } + chain + } + + /// Validate the complete parent chain, even if `main` was found before a cycle. + fn in_main(&mut self, id: NodeId, main: NodeId) -> Option { + if let Some(found) = self.parents.get(&(id, main)) { + return *found; + } + let read = || { + let mut current = Some(id); + let mut seen = BTreeSet::new(); + let mut found = false; + for _ in 0..Self::PARENTS { + let Some(id) = current else { return Some(found) }; + if !seen.insert(id) { + return None; + } + found |= id == main; + current = self.doc.node(id)?.parent; + } + current.is_none().then_some(found) + }; + let found = read(); + if self.parents.len() < Self::CACHE { + self.parents.insert((id, main), found); + } + found + } + + fn member(&mut self, source: NodeId, main: NodeId, target: NodeId) -> Option { + let chain = self.chain(source)?; + if chain.contains(&target) { + return None; + } + let mut member = None; + for id in chain { + if self.in_main(id, main)? && id != main && member.is_none() { + member = Some(id); + } + } + member + } + + /// Nearest enclosing instances and their current mains. Slots with own content end scope. + fn owners(&self, id: NodeId) -> Option> { + let mut owners = Vec::new(); + let mut current = self.doc.node(id)?.parent; + let mut seen = BTreeSet::from([id]); + for _ in 0..Self::PARENTS { + let Some(id) = current else { return Some(owners) }; + if !seen.insert(id) { + return None; + } + let n = self.doc.node(id)?; + if holds_content(n) { + return Some(owners); + } + if let NodeKind::Instance { component, .. } = &n.kind { + owners.push((id, *component)); + } + current = n.parent; + } + current.is_none().then_some(owners) + } + + fn contextual(&mut self, source: NodeId, owners: &[(NodeId, NodeId)]) -> bool { + owners.iter().any(|(_, main)| self.in_main(source, *main) == Some(true) && source != *main) + } + + /// A contextual child belongs to the same placed slot as its enclosing instance. + fn valid(&mut self, source: NodeId, target: NodeId, owners: &[(NodeId, NodeId)]) -> bool { + let Some((owner, main)) = owners.first() else { return false }; + if self.member(source, *main, target).is_none() { + return false; + } + if self.in_main(source, *main) == Some(true) { + return true; + } + let Some(source_owner) = self.doc.node(*owner).and_then(|n| n.source) else { return false }; + matches!(self.doc.node(source_owner).map(|n| &n.kind), Some(NodeKind::Instance { component, .. }) if component == main) + && self.owners(source).and_then(|owners| owners.first().copied()).is_some_and(|(id, _)| id == source_owner) + } + + fn placed(&mut self, id: NodeId, owners: &[(NodeId, NodeId)]) -> Option { + let source = self.doc.node(id)?.source?; + if !matches!(self.doc.node(source)?.kind, NodeKind::Instance { .. } | NodeKind::Frame) + || !self.valid(source, id, owners) + || !self.contextual(source, owners) + { + return None; + } + Some(source) + } + + fn beneath_swap(&mut self, owners: &[(NodeId, NodeId)], stop: NodeId) -> bool { + for (index, (id, main)) in owners.iter().enumerate() { + if *id == stop { + break; + } + let changed = if let Some(changed) = self.main_changes.get(id) { + *changed + } else { + let changed = self.placed(*id, &owners[index + 1..]).is_some_and(|source| match self.doc.node(source).map(|n| &n.kind) { + Some(NodeKind::Instance { component, .. }) => component != main, + Some(NodeKind::Frame) => true, + _ => false, + }); + if self.main_changes.len() < Self::CACHE { + self.main_changes.insert(*id, changed); + } + changed + }; + if changed { + return true; + } + } + false + } +} + /// Re-derive `source` links and overrides of instance `inst` against its own main by comparing /// values (after detaching an outer instance, restoring a main, or copying a nested instance out): /// the look is kept exactly. Values a component property sets aren't overrides. @@ -1722,22 +1881,28 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) { diff_groups(a, b).into_iter().filter(|g| !matches!(*g, "size" | "position")).map(str::to_string).collect() }; let mut updates = Vec::new(); + let mut sources = RebaseSources::new(doc); + let root_owners = sources.owners(inst).unwrap_or_default(); + let placed_source = sources.placed(inst, &root_owners); + let mut contexts = vec![(inst, component)]; + contexts.extend_from_slice(&root_owners); for (id, path) in &mine { let Some(n) = doc.node(*id) else { continue }; - // Follow the source chain into the main first (exact), then fall back to the name path. - let mut main = None; - let mut cur = n.source; - for _ in 0..MAX_DEPTH { - let Some(c) = cur else { break }; - if c != component && doc.contains(c) && doc.is_ancestor(component, c) { - main = Some(c); - break; - } - cur = doc.node(c).and_then(|x| x.source); + let owners = sources.owners(*id).unwrap_or_default(); + let nearest_main = owners.first().map(|(_, main)| *main); + let valid_source = n.source.filter(|source| sources.valid(*source, *id, &owners)); + let mut main = valid_source.and_then(|source| sources.member(source, component, *id)); + // A swapped or Frame-to-Instance subtree is compared to its current main by the inner + // rebase. An outer name path must not rebind those children to the old expanded copies. + if main.is_none() && valid_source.is_some() && sources.beneath_swap(&owners, inst) { + updates.push((*id, valid_source, n.overrides.clone())); + continue; } - let main = main.or_else(|| theirs.get(path).copied()); + main = main + .or_else(|| theirs.get(path).copied().filter(|source| nearest_main.is_some_and(|scope| sources.member(*source, scope, *id).is_some()))); + let source = valid_source.filter(|source| sources.contextual(*source, &contexts)).filter(|_| main.is_some()).or(main); let mut overrides: BTreeSet = n.overrides.iter().filter(|g| !matches!(g.as_str(), "component" | "props")).cloned().collect(); - if let Some(m) = main.and_then(|m| doc.node(m)) { + if let Some(m) = source.and_then(|source| doc.node(source)) { let mut bound: Vec<&str> = m.prop_refs.keys().map(|f| field_group(f)).collect(); bound.extend(variable_groups(n, m)); overrides.extend(value_groups(m, n).into_iter().filter(|g| !bound.contains(&g.as_str()))); @@ -1745,36 +1910,51 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) { overrides.insert("component".into()); } } - updates.push((*id, main, overrides)); + updates.push((*id, source, overrides)); } - for (id, main, overrides) in updates { - if let Some(n) = doc.node_mut(id) { - n.source = main; - n.overrides = if main.is_some() { overrides } else { BTreeSet::new() }; - } - } - let root_groups = match (doc.node(component), doc.node(inst)) { + // A placed nested root compares to its actual slot. A detached root compares to its own main. + let root_base = placed_source.or_else(|| { + let chain = sources.chain(component)?; + (!chain.contains(&inst) && sources.in_main(component, inst) == Some(false)).then_some(component) + }); + let root_groups = match (root_base.and_then(|source| doc.node(source)), doc.node(inst)) { (Some(m), Some(n)) => { let from_variables = variable_groups(n, m); let mut groups: Vec = value_groups(m, n) .into_iter() .filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str())) .collect(); - // A layer name of its own (a nested instance renamed in its old main) stays. - if n.name != default_instance_name(doc, component) { + let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) }; + if named { groups.push("name".into()); } + if placed_source.is_some() + && matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b) + { + groups.push("component".into()); + } groups } _ => Vec::new(), }; + drop(sources); + for (id, main, overrides) in updates { + if let Some(n) = doc.node_mut(id) { + n.source = main; + n.overrides = if main.is_some() { overrides } else { BTreeSet::new() }; + } + } if let Some(n) = doc.node_mut(inst) { - n.source = Some(component); + n.source = root_base; n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position")); n.overrides.extend(root_groups); } } +#[cfg(test)] +#[path = "components_rebase_source_tests.rs"] +mod rebase_source_tests; + /// A set's default variant: the one in its top-left corner (nearest to it, then the top-most, then /// the left-most). pub fn default_variant(doc: &Document, set: NodeId) -> Option { diff --git a/crates/engine/src/components_rebase_source_tests.rs b/crates/engine/src/components_rebase_source_tests.rs new file mode 100644 index 00000000..b272b82f --- /dev/null +++ b/crates/engine/src/components_rebase_source_tests.rs @@ -0,0 +1,244 @@ +//! Contextual import sources survive rebasing; detached and swapped subtrees use their current mains. + +use super::*; + +fn add(doc: &mut Document, parent: NodeId, kind: NodeKind, name: &str) -> NodeId { + doc.create(parent, kind, name, 0.0, 0.0, 20.0, 20.0).unwrap() +} + +fn component(doc: &mut Document, parent: NodeId, name: &str) -> NodeId { + add(doc, parent, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, name) +} + +fn mirror(doc: &mut Document, source: NodeId, parent: NodeId) -> NodeId { + let mut n = doc.node(source).unwrap().clone(); + let children = std::mem::take(&mut n.children); + n.id = doc.alloc_id(); + n.parent = None; + n.source = Some(source); + let id = n.id; + doc.insert(n, parent, None).unwrap(); + for child in children { + mirror(doc, child, id); + } + id +} + +fn instance(doc: &mut Document, main: NodeId, parent: NodeId, name: &str) -> NodeId { + let mut n = doc.node(main).unwrap().clone(); + let children = std::mem::take(&mut n.children); + n.id = doc.alloc_id(); + n.kind = NodeKind::Instance { component: main, props: BTreeMap::new() }; + n.name = name.into(); + n.parent = None; + n.source = None; + let id = n.id; + doc.insert(n, parent, None).unwrap(); + for child in children { + mirror(doc, child, id); + } + id +} + +struct Scene { + doc: Document, + inner: NodeId, + base: NodeId, + slot: NodeId, + context: NodeId, + root: NodeId, + nested: NodeId, + child: NodeId, +} + +fn scene() -> Scene { + let mut doc = Document::new("Source context"); + let page = doc.first_page().unwrap(); + let inner = component(&mut doc, page, "Inner"); + let base = add(&mut doc, inner, NodeKind::Rectangle, "Body"); + doc.node_mut(base).unwrap().transform = Affine::translate((2.0, 0.0)); + let outer = component(&mut doc, page, "Outer"); + let slot = instance(&mut doc, inner, outer, "Nested"); + doc.node_mut(slot).unwrap().source = Some(inner); + let context = doc.node(slot).unwrap().children[0]; + doc.node_mut(context).unwrap().transform = Affine::translate((3.0, 0.0)); + doc.node_mut(context).unwrap().opacity = 0.3; + let root = instance(&mut doc, outer, page, "Outer copy"); + let nested = doc.node(root).unwrap().children[0]; + let child = doc.node(nested).unwrap().children[0]; + doc.node_mut(child).unwrap().transform = Affine::translate((2.0, 0.0)); + doc.node_mut(child).unwrap().opacity = 0.2; + Scene { doc, inner, base, slot, context, root, nested, child } +} + +fn both(s: &mut Scene) { + rebase_instance(&mut s.doc, s.nested); + rebase_instance(&mut s.doc, s.root); +} + +#[test] +fn rebase_default_only_sources_promote_to_the_actual_outer_context() { + let mut s = scene(); + s.doc.node_mut(s.nested).unwrap().source = None; + s.doc.node_mut(s.child).unwrap().source = None; + both(&mut s); + assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot)); + assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context)); +} + +#[test] +fn rebase_rejects_a_context_source_from_another_nested_slot() { + let mut s = scene(); + let outer = s.doc.node(s.slot).unwrap().parent.unwrap(); + let sibling = instance(&mut s.doc, s.inner, outer, "Other nested slot"); + s.doc.node_mut(sibling).unwrap().source = Some(s.inner); + let sibling_child = s.doc.node(sibling).unwrap().children[0]; + mirror(&mut s.doc, sibling, s.root); + s.doc.node_mut(s.child).unwrap().source = Some(sibling_child); + both(&mut s); + assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot)); + assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context)); + assert_eq!(s.doc.node(sibling_child).unwrap().transform.translation().x, 2.0); + assert_eq!(s.doc.node(s.context).unwrap().transform.translation().x, 3.0); +} + +#[test] +fn rebase_does_not_mark_inherited_context_values_as_local_overrides() { + let mut s = scene(); + s.doc.node_mut(s.child).unwrap().opacity = 0.3; + both(&mut s); + assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context)); + assert!(!s.doc.node(s.child).unwrap().overrides.contains("opacity")); +} + +#[test] +fn rebase_preserves_multilevel_context_without_shortening_source_chains() { + let mut doc = Document::new("Nested source contexts"); + let page = doc.first_page().unwrap(); + let inner = component(&mut doc, page, "Inner"); + let base = add(&mut doc, inner, NodeKind::Rectangle, "Body"); + doc.node_mut(base).unwrap().transform = Affine::translate((2.0, 0.0)); + let wrapper = component(&mut doc, page, "Wrapper"); + let default_slot = instance(&mut doc, inner, wrapper, "Nested"); + doc.node_mut(default_slot).unwrap().source = Some(inner); + let outer = component(&mut doc, page, "Outer"); + let wrapper_slot = instance(&mut doc, wrapper, outer, "Wrapper slot"); + doc.node_mut(wrapper_slot).unwrap().source = Some(wrapper); + let source_slot = doc.node(wrapper_slot).unwrap().children[0]; + let context = doc.node(source_slot).unwrap().children[0]; + doc.node_mut(context).unwrap().transform = Affine::translate((3.0, 0.0)); + let root = instance(&mut doc, outer, page, "Outer copy"); + let wrapper_copy = doc.node(root).unwrap().children[0]; + let nested = doc.node(wrapper_copy).unwrap().children[0]; + let child = doc.node(nested).unwrap().children[0]; + doc.node_mut(nested).unwrap().name = "Renamed nested".into(); + doc.node_mut(child).unwrap().transform = Affine::translate((2.0, 0.0)); + for id in [nested, wrapper_copy, root] { + rebase_instance(&mut doc, id); + assert_eq!(doc.node(nested).unwrap().source, Some(source_slot)); + assert_eq!(doc.node(child).unwrap().source, Some(context)); + } + assert_eq!(doc.node(context).unwrap().source, Some(doc.node(default_slot).unwrap().children[0])); + assert_eq!(doc.node(context).unwrap().transform.translation().x, 3.0); + assert_eq!(doc.node(child).unwrap().transform.translation().x, 2.0); +} + +#[test] +fn rebase_preserves_placed_frame_to_instance_and_current_main_children() { + let mut doc = Document::new("Typed nested source"); + let page = doc.first_page().unwrap(); + let inner = component(&mut doc, page, "Inner"); + let body = add(&mut doc, inner, NodeKind::Rectangle, "Body"); + let outer = component(&mut doc, page, "Outer"); + let slot = add(&mut doc, outer, NodeKind::Frame, "Empty slot"); + let root = instance(&mut doc, outer, page, "Outer copy"); + let nested = doc.node(root).unwrap().children[0]; + doc.node_mut(nested).unwrap().kind = NodeKind::Instance { component: inner, props: BTreeMap::new() }; + let child = mirror(&mut doc, body, nested); + rebase_instance(&mut doc, nested); + rebase_instance(&mut doc, root); + assert_eq!(doc.node(nested).unwrap().source, Some(slot)); + assert_eq!(doc.node(child).unwrap().source, Some(body)); + assert!(matches!(doc.node(nested).unwrap().kind, NodeKind::Instance { component, .. } if component == inner)); +} + +#[test] +fn rebase_keeps_current_main_sources_and_component_delta_beneath_swap() { + let mut s = scene(); + let page = s.doc.first_page().unwrap(); + let replacement = component(&mut s.doc, page, "Replacement"); + let body = add(&mut s.doc, replacement, NodeKind::Rectangle, "Body"); + s.doc.node_mut(body).unwrap().transform = Affine::translate((7.0, 0.0)); + s.doc.node_mut(s.nested).unwrap().kind = NodeKind::Instance { component: replacement, props: BTreeMap::new() }; + s.doc.remove(s.child).unwrap(); + s.child = mirror(&mut s.doc, body, s.nested); + s.doc.node_mut(s.child).unwrap().transform = Affine::translate((8.0, 0.0)); + s.doc.node_mut(s.nested).unwrap().name = "Renamed swap".into(); + rebase_instance(&mut s.doc, s.nested); + assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot)); + assert!(s.doc.node(s.nested).unwrap().overrides.contains("component")); + rebase_instance(&mut s.doc, s.root); + assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot)); + assert_eq!(s.doc.node(s.child).unwrap().source, Some(body)); + assert!(s.doc.node(s.nested).unwrap().overrides.contains("component")); + assert_eq!(s.doc.node(s.child).unwrap().transform.translation().x, 8.0); +} + +#[test] +fn rebase_detached_nested_uses_own_main_and_keeps_inherited_geometry() { + let mut s = scene(); + let page = s.doc.first_page().unwrap(); + let detached = mirror(&mut s.doc, s.nested, page); + let child = s.doc.node(detached).unwrap().children[0]; + // The old source references still exist, but their Outer context is absent here. + s.doc.node_mut(detached).unwrap().source = Some(s.slot); + s.doc.node_mut(child).unwrap().source = Some(s.context); + s.doc.node_mut(child).unwrap().transform = Affine::translate((3.0, 0.0)); + rebase_instance(&mut s.doc, detached); + assert_eq!(s.doc.node(detached).unwrap().source, Some(s.inner)); + assert_eq!(s.doc.node(child).unwrap().source, Some(s.base)); + assert_eq!(s.doc.node(child).unwrap().transform.translation().x, 3.0); + assert_eq!(s.doc.node(s.base).unwrap().transform.translation().x, 2.0); +} + +#[test] +fn rebase_does_not_preserve_existing_foreign_or_stale_sources() { + let mut s = scene(); + let page = s.doc.first_page().unwrap(); + let foreign = component(&mut s.doc, page, "Foreign"); + let foreign_body = add(&mut s.doc, foreign, NodeKind::Rectangle, "Body"); + s.doc.node_mut(s.child).unwrap().source = Some(foreign_body); + both(&mut s); + assert_ne!(s.doc.node(s.child).unwrap().source, Some(foreign_body)); + assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context)); + let mut s = scene(); + let page = s.doc.first_page().unwrap(); + let replacement = component(&mut s.doc, page, "Replacement"); + let body = add(&mut s.doc, replacement, NodeKind::Rectangle, "Body"); + s.doc.node_mut(s.nested).unwrap().kind = NodeKind::Instance { component: replacement, props: BTreeMap::new() }; + s.doc.node_mut(s.child).unwrap().source = Some(s.context); + both(&mut s); + assert_ne!(s.doc.node(s.child).unwrap().source, Some(s.context), "the old default-main child is stale after a swap"); + assert!(s.doc.node(s.child).unwrap().source.is_none() || s.doc.node(s.child).unwrap().source == Some(body)); +} + +#[test] +fn rebase_rejects_source_and_parent_cycles_with_bounded_lookups() { + let mut s = scene(); + s.doc.node_mut(s.base).unwrap().source = Some(s.context); + both(&mut s); + assert_eq!(s.doc.node(s.child).unwrap().source, None); + let mut s = scene(); + s.doc.node_mut(s.context).unwrap().parent = Some(s.context); + both(&mut s); + assert_ne!(s.doc.node(s.child).unwrap().source, Some(s.context)); + let mut s = scene(); + s.doc.node_mut(s.nested).unwrap().parent = Some(s.child); + s.doc.node_mut(s.child).unwrap().parent = Some(s.nested); + let sources = RebaseSources::new(&s.doc); + assert!(sources.owners(s.child).is_none()); + let mut s = scene(); + s.doc.node_mut(s.root).unwrap().kind = NodeKind::Instance { component: s.root, props: BTreeMap::new() }; + rebase_instance(&mut s.doc, s.root); + assert_ne!(s.doc.node(s.root).unwrap().source, Some(s.root)); +} diff --git a/crates/engine/src/fig_import_tests.rs b/crates/engine/src/fig_import_tests.rs index d6241448..f0ea0886 100644 --- a/crates/engine/src/fig_import_tests.rs +++ b/crates/engine/src/fig_import_tests.rs @@ -50,3 +50,43 @@ fn local_samples_match_their_exports() { } } } + +/// Layers of an instance copy keep their link to the layer they stand for when the file is +/// opened, also after they were renamed in the copy (a name is no identity), and the override +/// that a rename is stays an override. +#[test] +fn opened_fig_links_instance_layers_by_identity_not_by_name() { + use base64::Engine as _; + let mut s = Session::new(); + let make = |s: &mut Session, cmd: &str, p: serde_json::Value| s.execute(cmd, &p).unwrap()["id"].as_u64().map(figcraft_doc::NodeId).unwrap(); + let body = make(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 20, "name": "Inner"})); + let label = make(&mut s, "text.create", json!({"text": "Label", "x": 2, "y": 2, "parent": body, "props": {"name": "Label"}})); + let inner = make(&mut s, "component.create", json!({"ids": [body]})); + let frame = make(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 200, "height": 60, "name": "Outer"})); + let outer = make(&mut s, "component.create", json!({"ids": [frame]})); + let slot = make(&mut s, "instance.create", json!({"component": inner, "x": 0, "y": 0, "parent": outer})); + s.execute("node.set", &json!({"id": slot, "props": {"name": "Slot"}})).unwrap(); + let copy = make(&mut s, "instance.create", json!({"component": outer, "x": 0, "y": 200})); + let copy_slot = s.doc().unwrap().node(copy).unwrap().children[0]; + let copy_label = s.doc().unwrap().node(copy_slot).unwrap().children[0]; + s.execute("node.set", &json!({"id": copy_slot, "props": {"name": "Renamed slot"}})).unwrap(); + s.execute("node.set", &json!({"id": copy_label, "props": {"name": "Renamed label"}})).unwrap(); + let _ = label; + + let exported = s.execute("file.export", &json!({"format": "fig"})).unwrap(); + let path = std::env::temp_dir().join(format!("figcraft-slots-{}.fig", std::process::id())); + std::fs::write(&path, base64::engine::general_purpose::STANDARD.decode(exported["data"].as_str().unwrap()).unwrap()).unwrap(); + let mut opened = Session::new(); + let result = opened.execute("file.open", &json!({"path": path.to_string_lossy()})); + let _ = std::fs::remove_file(&path); + result.unwrap(); + let doc = opened.doc().unwrap(); + let outer = doc.nodes().find(|n| n.name == "Outer" && matches!(n.kind, NodeKind::Component { .. })).unwrap(); + let copy = doc.nodes().find(|n| n.name == "Outer" && matches!(n.kind, NodeKind::Instance { .. })).unwrap(); + let slot = doc.node(copy.children[0]).unwrap(); + let label = doc.node(slot.children[0]).unwrap(); + assert_eq!((slot.name.as_str(), label.name.as_str()), ("Renamed slot", "Renamed label")); + assert_eq!(slot.source, Some(outer.children[0])); + assert_eq!(label.source, Some(doc.node(outer.children[0]).unwrap().children[0])); + assert!(slot.overrides.contains("name") && label.overrides.contains("name")); +} diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 73c599a4..d7bafe82 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -1221,9 +1221,16 @@ pub fn check_read_only(cmd: &str, before: &Document, after: &Document) -> std::r if !variables_unchanged(before, after) { return Err(READ_ONLY.into()); } - let Some(store) = after.library_store().or_else(|| before.library_store()) else { return Ok(()) }; - let touched = |d: &Document, id: NodeId| d.contains(id) && d.page_of(id) == Some(store); - if after.dirty().iter().any(|id| touched(before, *id) || touched(after, *id)) { + let changed_remote = |id| { + if !before.is_remote(id) && !after.is_remote(id) { + return false; + } + match (before.node_arc(id), after.node_arc(id)) { + (Some(old), Some(new)) => !Arc::ptr_eq(old, new), + _ => true, + } + }; + if after.dirty().iter().any(|id| changed_remote(*id)) { return Err(READ_ONLY.into()); } Ok(()) diff --git a/crates/engine/src/library_tests.rs b/crates/engine/src/library_tests.rs index 23d5af09..c229e4e0 100644 --- a/crates/engine/src/library_tests.rs +++ b/crates/engine/src/library_tests.rs @@ -45,6 +45,38 @@ fn path_str(p: &std::path::Path) -> String { p.to_string_lossy().into_owned() } +fn dirty_library_document() -> (Document, NodeId) { + let mut doc = Document::new("Dirty library cache"); + let store = doc.ensure_library_store(); + let remote = doc.alloc_id(); + doc.insert(Node::new(remote, NodeKind::Frame, "Remote", 10.0, 10.0), store, None).unwrap(); + (doc, remote) +} + +/// An imported cache is dirty until the first edit; that alone is no edit of the library copies. +#[test] +fn read_only_allows_unchanged_predirty_remote_nodes() { + let (before, remote) = dirty_library_document(); + assert!(before.dirty().contains(&remote)); + + let mut after = before.clone(); + after.add_page("Unrelated page"); + assert!(after.dirty().contains(&remote), "the imported remote node remains in the pending dirty set"); + check_read_only("page.add", &before, &after).unwrap(); + + let mut edited = before.clone(); + edited.get_mut(remote).unwrap().opacity = 0.5; + assert_eq!(check_read_only("node.set", &before, &edited), Err(READ_ONLY.into())); + + let mut moved = before.clone(); + moved.reparent(remote, before.first_page().unwrap(), None).unwrap(); + assert_eq!(check_read_only("node.reparent", &before, &moved), Err(READ_ONLY.into())); + + let mut deleted = before.clone(); + deleted.remove(remote).unwrap(); + assert_eq!(check_read_only("edit.delete", &before, &deleted), Err(READ_ONLY.into())); +} + /// The library file's layers. struct Kit { button: NodeId, @@ -179,8 +211,16 @@ fn add_place_and_update_keep_overrides() { NodeKind::Instance { component, .. } => component, _ => panic!("not an instance"), }; + let unchanged = s.doc().unwrap().clone(); let e = s.execute("node.set", &json!({"id": remote_button, "props": {"fill": "#ff0000"}})).unwrap_err(); assert!(e.to_string().contains("can't be edited"), "{e}"); + assert_eq!(s.doc().unwrap(), &unchanged, "a refused remote edit rolls back"); + for (cmd, args) in + [("node.reparent", json!({"ids": [remote_button], "parent": s.page().unwrap()})), ("edit.delete", json!({"ids": [remote_button]}))] + { + assert!(s.execute(cmd, &args).is_err(), "{cmd} must preserve read-only copies"); + assert_eq!(s.doc().unwrap(), &unchanged, "refused {cmd} rolls back"); + } let style = s.doc().unwrap().styles[0].id.clone(); assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err()); assert!(s.execute("style.delete", &json!({"style": style})).is_err()); @@ -281,10 +321,16 @@ fn library_styles_apply_and_import_with_pages() { let copy = doc.nodes().find(|n| n.name == node(&s, inst).name && matches!(n.kind, NodeKind::Instance { .. }) && !doc.is_remote(n.id)).unwrap(); let NodeKind::Instance { component, .. } = copy.kind else { panic!("not an instance") }; assert!(doc.is_remote(component)); + let store_roots = |s: &Session| { + let doc = s.doc().unwrap(); + doc.library_store().and_then(|id| doc.node(id)).map_or(0, |n| n.children.len()) + }; + assert_eq!(store_roots(&t), 2, "recorded copies are not copied twice"); // A second import shares the copies it already has. ex(&mut t, "file.import", json!({"path": path})); healthy(&t); assert_eq!(t.doc().unwrap().libraries.components.len(), 2); + assert_eq!(store_roots(&t), 2); let _ = std::fs::remove_dir_all(&d); } diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 66a00129..9f9bacab 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -6,7 +6,7 @@ //! are expanded here from the main component (overrides and derived layout matched by id path, //! component property values applied) so the import looks as saved. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::rc::Rc; use figcraft_doc::geom::{Affine, Point}; @@ -33,6 +33,9 @@ pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec) { let mut m = Mapper::new(msg); let doc = m.document(); let mut out = json!({"name": name, "document": doc}); + if !m.library_contexts.is_empty() { + out[crate::rest::FIG_LIBRARY_CONTEXTS_FIELD] = Value::Array(std::mem::take(&mut m.library_contexts)); + } if !m.styles.is_empty() { out["styles"] = Value::Object(std::mem::take(&mut m.styles)); } @@ -259,6 +262,12 @@ struct Mapper<'a> { /// Descriptions of components and component sets (REST `components` / `componentSets`). components: Obj, sets: Obj, + /// Component-bearing branches of the file's internal canvases (cached library definitions). + library_contexts: Vec, + /// Layers visited while scanning internal canvases, across all of them. + library_scan_count: usize, + /// Most layers produced or scanned; the one place the cap comes from. + max_nodes: usize, warnings: Vec, count: usize, } @@ -268,9 +277,19 @@ impl<'a> Mapper<'a> { let mut nodes = BTreeMap::new(); let mut kids: BTreeMap> = BTreeMap::new(); let mut prop_defs = BTreeMap::new(); - for n in msg.get("nodeChanges").map(Kv::list).unwrap_or_default() { + let changes = msg.get("nodeChanges").map(Kv::list).unwrap_or_default(); + // The last change of a GUID wins; a REMOVED change deletes what came before it. + for n in changes { let Some(id) = n.get("guid").and_then(guid) else { continue }; if n.get("phase").and_then(Kv::str) == Some("REMOVED") { + nodes.remove(&id); + } else { + nodes.insert(id, n); + } + } + for n in changes { + let Some(id) = n.get("guid").and_then(guid) else { continue }; + if !nodes.get(&id).is_some_and(|latest| std::ptr::eq(*latest, n)) { continue; } if let Some(p) = n.get("parentIndex") @@ -285,7 +304,6 @@ impl<'a> Mapper<'a> { prop_defs.insert(did.clone(), (format!("{name}#{did}"), ty)); } } - nodes.insert(id, n); } for list in kids.values_mut() { list.sort(); @@ -302,7 +320,21 @@ impl<'a> Mapper<'a> { let version = n.get("version").and_then(Kv::str).filter(|v| !v.is_empty() && v.len() <= MAX_ASSET_PART).unwrap_or(""); assets.entry(key.to_string()).or_default().entry(version.to_string()).or_insert(id); } - Self { nodes, kids, blobs, prop_defs, assets, styles: Obj::new(), components: Obj::new(), sets: Obj::new(), warnings: Vec::new(), count: 0 } + Self { + nodes, + kids, + blobs, + prop_defs, + assets, + styles: Obj::new(), + components: Obj::new(), + sets: Obj::new(), + library_contexts: Vec::new(), + library_scan_count: 0, + max_nodes: MAX_NODES, + warnings: Vec::new(), + count: 0, + } } /// The id of the resource a `VariableSetID` (or `VariableID`, `StyleId`) names: its `guid`, @@ -345,6 +377,8 @@ impl<'a> Mapper<'a> { None => self.nodes.iter().filter(|(_, n)| n.get("type").and_then(Kv::str) == Some("CANVAS")).map(|(id, _)| id.clone()).collect(), }; let mut out = Vec::new(); + let mut internal = Vec::new(); + let mut visible_pages = Vec::new(); for p in pages { let Some(&n) = self.nodes.get(&p) else { continue }; if n.get("type").and_then(Kv::str) != Some("CANVAS") { @@ -353,16 +387,171 @@ impl<'a> Mapper<'a> { } // The hidden page that holds styles, variables and library copies. if n.get("internalOnly").and_then(Kv::bool) == Some(true) { + internal.push((p, n)); continue; } + visible_pages.push(p.clone()); out.push(self.page(&p, n)); } + let required_mains = self.referenced_component_mains(&visible_pages); + for (id, canvas) in internal { + if let Some(context) = self.library_context(&id, canvas, &required_mains) { + self.library_contexts.push(context); + } + } if out.is_empty() { self.warn("the file has no pages"); } json!({"id": doc_id.unwrap_or_else(|| "0:0".into()), "type": "DOCUMENT", "children": out}) } + /// The ids of the components the visible pages use, directly or through the components they + /// use in turn (nested instances, swap targets, overrides naming another main). + fn referenced_component_mains(&mut self, visible_pages: &[String]) -> BTreeSet { + let mut mains = BTreeSet::new(); + let mut visited = BTreeSet::new(); + let mut pending: Vec<(String, usize)> = visible_pages.iter().flat_map(|page| self.children(page)).map(|id| (id, 1)).collect(); + while let Some((id, depth)) = pending.pop() { + if depth > MAX_DEPTH { + self.warn(format!("component references below {MAX_DEPTH} layer levels were skipped")); + continue; + } + if !visited.insert(id.clone()) { + continue; + } + if visited.len() > self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the component reference scan reached the {max}-layer limit")); + break; + } + let Some(node) = self.nodes.get(&id) else { continue }; + for target in self.component_main_targets(node) { + if mains.insert(target.clone()) { + pending.push((target, 1)); + } + } + pending.extend(self.children(&id).into_iter().map(|child| (child, depth + 1))); + } + mains + } + + /// The mains a layer names: an instance's own, the ones its overrides swap in, the instance-swap + /// defaults and values of component properties. + fn component_main_targets(&self, node: &Kv<'a>) -> Vec { + let mut targets: Vec = node + .get("componentPropDefs") + .map(Kv::list) + .unwrap_or_default() + .iter() + .filter_map(|definition| { + self.is_instance_swap(definition.get("id")?)?; + definition.get("initialValue")?.get("guidValue").and_then(guid) + }) + .collect(); + if node.get("type").and_then(Kv::str) != Some("INSTANCE") { + return targets; + } + // The instance and the override rows below it (rows carry the same fields, nested). + let mut pending: Vec<(&Kv<'a>, usize)> = vec![(node, 0)]; + let mut rows = 0usize; + while let Some((row, depth)) = pending.pop() { + rows += 1; + if depth > MAX_DEPTH || rows > self.max_nodes { + break; + } + let data = row.get("symbolData"); + targets.extend(row.get("overriddenSymbolID").and_then(guid)); + targets.extend(data.and_then(|d| d.get("symbolID")).and_then(guid)); + for assignment in row.get("componentPropAssignments").map(Kv::list).unwrap_or_default() { + if assignment.get("defID").is_some_and(|def| self.is_instance_swap(def).is_some()) { + targets.extend(assignment.get("value").and_then(|v| v.get("guidValue")).and_then(guid)); + } + } + let overrides = data.and_then(|d| d.get("symbolOverrides")).map(Kv::list).unwrap_or_default(); + let derived = row.get("derivedSymbolData").map(Kv::list).unwrap_or_default(); + pending.extend(overrides.iter().chain(derived).map(|row| (row, depth + 1))); + } + targets + } + + fn is_instance_swap(&self, def: &Kv) -> Option<()> { + matches!(self.prop_defs.get(&guid(def)?), Some((_, "INSTANCE_SWAP"))).then_some(()) + } + + /// The branches of the internal canvas `id` that hold the `required_mains` (with the frames + /// they sit in), as a frame of its own: the cached definitions of components that come from a + /// library. `None` when it holds none. + fn library_context(&mut self, id: &str, canvas: &'a Kv<'a>, required_mains: &BTreeSet) -> Option { + let mut included = BTreeSet::new(); + let mut parents: BTreeMap = BTreeMap::new(); + // The canvas itself is never a layer of the walk, whatever the parent links say. + let mut visited = BTreeSet::from([id.to_string()]); + // Each entry: layer, depth, inside a required definition. + let mut pending: Vec<(String, usize, bool)> = self.children(id).into_iter().map(|child| (child, 1, false)).collect(); + while let Some((node_id, depth, in_definition)) = pending.pop() { + if depth > MAX_DEPTH { + self.warn(format!("internal library branches deeper than {MAX_DEPTH} levels were skipped")); + continue; + } + if !visited.insert(node_id.clone()) { + continue; + } + self.library_scan_count += 1; + if self.library_scan_count > self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the internal library scan reached the {max}-layer limit; the remaining branches were skipped")); + break; + } + let Some(node) = self.nodes.get(&node_id) else { continue }; + let kind = node.get("type").and_then(Kv::str); + let definition = (kind == Some("SYMBOL") || (kind == Some("FRAME") && node.get("isStateGroup").and_then(Kv::bool) == Some(true))) + && required_mains.contains(&node_id); + let in_definition = in_definition || definition; + if in_definition { + included.insert(node_id.clone()); + } + if definition { + // The frames around it keep the path its layers are addressed by. + let mut ancestor = parents.get(&node_id); + for _ in 0..=MAX_DEPTH { + let Some(parent) = ancestor else { break }; + if !included.insert(parent.clone()) { + break; + } + ancestor = parents.get(parent); + } + } + for child in self.children(&node_id).into_iter().rev() { + // A layer reached again keeps the parent it was first reached by, so the parents + // lead back to the canvas. + if visited.contains(&child) { + continue; + } + parents.entry(child.clone()).or_insert_with(|| node_id.clone()); + pending.push((child, depth + 1, in_definition)); + } + } + if included.is_empty() { + return None; + } + let mut context = Obj::new(); + context.insert("id".into(), json!(id)); + context.insert("type".into(), json!("FRAME")); + copy_str(&mut context, &View::of(canvas), "name", "name"); + context.insert("size".into(), json!({"x": 0.0, "y": 0.0})); + context.insert("relativeTransform".into(), rows(Affine::IDENTITY)); + context.insert(crate::rest::FIG_SOURCE_FIELD.into(), json!({"origin": id, "expanded": false, "wireFrame": true, "wireInstance": false})); + self.variable_bindings(&mut context, &View::of(canvas)); + let mut children = Vec::new(); + for child in self.children(id) { + children.extend(self.layer_filtered(&child, None, Affine::IDENTITY, None, None, 0, Some(&included))); + } + if !children.is_empty() { + context.insert("children".into(), Value::Array(children)); + } + Some(Value::Object(context)) + } + fn page(&mut self, id: &str, n: &'a Kv<'a>) -> Value { let v = View::of(n); let mut o = Obj::new(); @@ -405,6 +594,24 @@ impl<'a> Mapper<'a> { fit: Option, depth: usize, ) -> Option { + self.layer_filtered(id, inst, space, parent, fit, depth, None) + } + + /// [`Self::layer`], keeping only the layers in `included` (all when `None`) below the + /// top-level one: the cached library definitions of an internal canvas. + fn layer_filtered( + &mut self, + id: &str, + inst: Option<&Inst<'a>>, + space: Affine, + parent: Option<&View<'a>>, + fit: Option, + depth: usize, + included: Option<&BTreeSet>, + ) -> Option { + if included.is_some_and(|ids| !ids.contains(id)) { + return None; + } if depth > MAX_DEPTH { self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); return None; @@ -457,6 +664,11 @@ impl<'a> Mapper<'a> { self.count += 1; let mut o = Obj::new(); o.insert("id".into(), json!(rest_id)); + // Private provenance for the .fig importer; ordinary REST import ignores it. + o.insert( + crate::rest::FIG_SOURCE_FIELD.into(), + json!({"origin": id, "expanded": inst.is_some(), "wireFrame": own == "FRAME", "wireInstance": own == "INSTANCE"}), + ); o.insert("type".into(), json!(ty)); copy_str(&mut o, &view, "name", "name"); self.common(&mut o, &view, ty, space, parent); @@ -491,7 +703,8 @@ impl<'a> Mapper<'a> { let main = self.nodes.get(&sym).map(|m| View::of(m)); let fit = Fit::frame(main.and_then(|m| m.size()), view.size()); for c in self.children(&sym) { - kids.extend(self.layer(&c, Some(&sub), child_space, Some(&view), fit, depth + 1)); + // The main's branch can live outside the cached context being mapped. + kids.extend(self.layer_filtered(&c, Some(&sub), child_space, Some(&view), fit, depth + 1, None)); } } } else { @@ -503,7 +716,7 @@ impl<'a> Mapper<'a> { (Some(_), _) => Fit::frame(View::of(base).size(), view.size()), }; for c in self.children(id) { - kids.extend(self.layer(&c, inst, child_space, Some(&view), fit, depth + 1)); + kids.extend(self.layer_filtered(&c, inst, child_space, Some(&view), fit, depth + 1, included)); } } if stand.is_some_and(|s| s.0 == "FRAME") { diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index ec2a0e23..24f304a3 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -21,6 +21,8 @@ pub mod kiwi; mod map; mod network; #[cfg(test)] +mod source_identity_tests; +#[cfg(test)] mod tests; use std::io::Read; @@ -64,7 +66,7 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), Forma let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name); let (json, mut warnings) = map::to_rest(&msg, file_name); let images = |r: &str| archive.image(r); - let (doc, more) = crate::rest::import(&json, &images)?; + let (doc, more) = crate::rest::import_fig(&json, &images)?; warnings.extend(more.into_iter().filter_map(map::adapt_warning)); Ok((doc, warnings)) } diff --git a/crates/format/src/fig/source_identity_tests.rs b/crates/format/src/fig/source_identity_tests.rs new file mode 100644 index 00000000..955df1da --- /dev/null +++ b/crates/format/src/fig/source_identity_tests.rs @@ -0,0 +1,938 @@ +//! Synthetic .fig source identity: cached library components, contextual sources, main switches +//! and invalid provenance. + +use figcraft_doc::color::PaintKind; +use figcraft_doc::{Document, NodeId, NodeKind, PropDef}; +use serde_json::{Value, json}; + +use super::kiwi::{Kv, Schema}; +use super::*; + +type Fields = Vec<(&'static str, Kv<'static>)>; + +fn o(fields: Fields) -> Kv<'static> { + Kv::Object(fields) +} + +fn g(id: u64) -> Kv<'static> { + o(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(id))]) +} + +fn s(value: &str) -> Kv<'static> { + Kv::Str(value.into()) +} + +fn matrix(x: f32) -> Kv<'static> { + o(vec![ + ("m00", Kv::Float(1.0)), + ("m01", Kv::Float(0.0)), + ("m02", Kv::Float(x)), + ("m10", Kv::Float(0.0)), + ("m11", Kv::Float(1.0)), + ("m12", Kv::Float(0.0)), + ]) +} + +fn node(id: u64, parent: Option, ty: &'static str, name: &str, more: Fields) -> Kv<'static> { + let mut fields = vec![("guid", g(id)), ("phase", Kv::Enum("CREATED")), ("type", Kv::Enum(ty)), ("name", s(name))]; + if let Some(parent) = parent { + fields.push(("parentIndex", o(vec![("guid", g(parent)), ("position", s(&format!("{id:04}")))]))); + } + fields.extend(more); + o(fields) +} + +fn symbol(main: u64, overrides: Vec>) -> Kv<'static> { + o(vec![("symbolID", g(main)), ("symbolOverrides", Kv::Array(overrides))]) +} + +fn row(path: &[u64], more: Fields) -> Kv<'static> { + let mut fields = vec![("guidPath", o(vec![("guids", Kv::Array(path.iter().map(|id| g(*id)).collect()))]))]; + fields.extend(more); + o(fields) +} + +fn solid(r: f32, g: f32, b: f32) -> Kv<'static> { + o(vec![ + ("type", Kv::Enum("SOLID")), + ("color", o(vec![("r", Kv::Float(r)), ("g", Kv::Float(g)), ("b", Kv::Float(b)), ("a", Kv::Float(1.0))])), + ("opacity", Kv::Float(1.0)), + ("visible", Kv::Bool(true)), + ]) +} + +fn schema() -> Schema { + Schema::parse(export::SCHEMA).unwrap() +} + +fn cached_component_message() -> Kv<'static> { + o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "DOCUMENT", "Document", vec![]), + node(2, Some(1), "CANVAS", "Visible page", vec![]), + node( + 3, + Some(1), + "CANVAS", + "Remote library", + vec![ + ("internalOnly", Kv::Bool(true)), + ( + "variableModeBySetMap", + o(vec![("entries", Kv::Array(vec![o(vec![("variableSetID", o(vec![("guid", g(30))])), ("variableModeID", g(52))])]))]), + ), + ], + ), + node( + 30, + Some(3), + "VARIABLE_SET", + "Remote theme", + vec![( + "variableSetModes", + Kv::Array(vec![ + o(vec![("id", g(51)), ("name", s("Light")), ("sortPosition", s("!"))]), + o(vec![("id", g(52)), ("name", s("Dark")), ("sortPosition", s("\""))]), + ]), + )], + ), + node( + 9, + Some(3), + "FRAME", + "Cached component set", + vec![("isStateGroup", Kv::Bool(true)), ("size", o(vec![("x", Kv::Float(24.0)), ("y", Kv::Float(24.0))])), ("transform", matrix(0.0))], + ), + node( + 10, + Some(9), + "SYMBOL", + "Cached remote icon", + vec![("size", o(vec![("x", Kv::Float(24.0)), ("y", Kv::Float(24.0))])), ("transform", matrix(0.0))], + ), + node( + 11, + Some(10), + "ROUNDED_RECTANGLE", + "Cached path", + vec![("size", o(vec![("x", Kv::Float(24.0)), ("y", Kv::Float(24.0))])), ("transform", matrix(0.0))], + ), + node( + 12, + Some(10), + "INSTANCE", + "Nested cached instance", + vec![ + ("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), + ("transform", matrix(0.0)), + ("symbolData", symbol(22, vec![row(&[23], vec![("fillPaints", Kv::Array(vec![solid(0.2, 0.7, 0.1)]))])])), + ], + ), + node( + 13, + Some(9), + "SYMBOL", + "Nested cached main", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 14, + Some(13), + "ROUNDED_RECTANGLE", + "Nested cached path", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 15, + Some(10), + "INSTANCE", + "Nested internal instance", + vec![ + ("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), + ("transform", matrix(0.0)), + ("symbolData", symbol(13, vec![])), + ], + ), + node( + 20, + Some(2), + "INSTANCE", + "Visible icon instance", + vec![ + ("size", o(vec![("x", Kv::Float(24.0)), ("y", Kv::Float(24.0))])), + ("transform", matrix(0.0)), + ("symbolData", symbol(10, vec![])), + ], + ), + node( + 22, + Some(2), + "SYMBOL", + "Visible nested main", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 23, + Some(22), + "ROUNDED_RECTANGLE", + "Visible nested path", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + ]), + )]) +} + +fn cached_component_fixture() -> Vec { + let schema = schema(); + super::tests::canvas(&schema, &cached_component_message()) +} + +fn cached_component_override_fixture() -> Vec { + let mut message = cached_component_message(); + let Kv::Object(fields) = &mut message else { panic!("cached component fixture has an unexpected wire shape") }; + let changes = &mut fields.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap().1; + let Kv::Array(nodes) = changes else { panic!("cached component fixture has an unexpected wire shape") }; + let instance = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Visible icon instance")).unwrap(); + let Kv::Object(fields) = instance else { panic!("cached component fixture has an unexpected wire shape") }; + fields.iter_mut().find(|(key, _)| *key == "symbolData").unwrap().1 = + symbol(10, vec![row(&[12], vec![("symbolData", symbol(22, vec![row(&[26], vec![("overriddenSymbolID", g(24))])]))])]); + nodes.extend([ + node( + 24, + Some(3), + "SYMBOL", + "Overridden cached main", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 25, + Some(24), + "ROUNDED_RECTANGLE", + "Overridden cached path", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 26, + Some(22), + "INSTANCE", + "Deep cached instance", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0)), ("symbolData", symbol(27, vec![]))], + ), + node( + 27, + Some(3), + "SYMBOL", + "Base deep main", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 28, + Some(27), + "ROUNDED_RECTANGLE", + "Base deep path", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + ]); + super::tests::canvas(&schema(), &message) +} + +fn cached_component_default_swap_fixture() -> Vec { + let mut message = cached_component_message(); + let Kv::Object(fields) = &mut message else { panic!("cached component fixture has an unexpected wire shape") }; + let changes = &mut fields.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap().1; + let Kv::Array(nodes) = changes else { panic!("cached component fixture has an unexpected wire shape") }; + let owner = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Visible nested main")).unwrap(); + let Kv::Object(fields) = owner else { panic!("cached component fixture has an unexpected wire shape") }; + fields.push(( + "componentPropDefs", + Kv::Array(vec![o(vec![ + ("id", g(90)), + ("name", s("Icon")), + ("type", Kv::Enum("INSTANCE_SWAP")), + ("initialValue", o(vec![("guidValue", g(24))])), + ])]), + )); + nodes.extend([ + node( + 24, + Some(3), + "SYMBOL", + "Default cached main", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 25, + Some(24), + "ROUNDED_RECTANGLE", + "Default cached path", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + ]); + super::tests::canvas(&schema(), &message) +} + +fn assert_default_swap(doc: &Document) { + let owner = named_node(doc, "Visible nested main"); + let target = named_node(doc, "Default cached main"); + let NodeKind::Component { props, .. } = &doc.node(owner).unwrap().kind else { panic!("visible component lost its property definition") }; + assert_eq!(props.get("Icon"), Some(&PropDef::InstanceSwap { default: target, preferred: Vec::new() }), "{props:?}"); + assert!(doc.is_remote(target)); +} + +fn named_node(doc: &Document, name: &str) -> NodeId { + doc.nodes().find(|node| node.name == name).unwrap().id +} + +fn assert_dark_remote_mode(doc: &Document, node: NodeId) { + let collection = doc.variables.collections.iter().find(|collection| collection.name == "Remote theme").unwrap(); + let dark = collection.modes.iter().find(|mode| mode.name == "Dark").unwrap(); + assert_eq!(doc.modes_for(node).get(&collection.id), Some(&dark.id)); +} + +fn assert_no_linked_library_records(doc: &Document) { + assert!(doc.libraries.enabled.is_empty()); + assert!(doc.libraries.components.is_empty()); + assert!(doc.libraries.styles.is_empty()); + assert!(doc.libraries.collections.is_empty()); + assert!(doc.libraries.variables.is_empty()); +} + +#[test] +fn hidden_cached_component_is_retained_and_linked() { + let (doc, warnings) = import(&cached_component_fixture(), "Cached library.fig").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(doc.pages.len(), 1, "hidden cache must not become a visible page"); + let check = |doc: &Document| { + let store = doc.library_store().expect("private .fig cache is retained"); + let component = named_node(doc, "Cached remote icon"); + let path = doc.node(component).unwrap().children[0]; + let set = doc.node(component).unwrap().parent.unwrap(); + assert!(matches!(doc.node(set).unwrap().kind, NodeKind::ComponentSet { .. })); + assert_eq!(doc.node(doc.node(set).unwrap().parent.unwrap()).unwrap().parent, Some(store)); + assert!(doc.is_remote(component) && doc.is_remote(path)); + assert!(matches!(doc.node(component).unwrap().kind, NodeKind::Component { .. })); + assert_dark_remote_mode(doc, component); + assert_no_linked_library_records(doc); + // The visible instance resolves to the cached main, and its layer to the main's layer. + let instance = named_node(doc, "Visible icon instance"); + assert!(matches!(doc.node(instance).unwrap().kind, NodeKind::Instance { component: linked, .. } if linked == component)); + assert_eq!(doc.node(doc.node(instance).unwrap().children[0]).unwrap().source, Some(path)); + // A cached main nested in another cached main is kept too, and so is an instance in the + // cache of a visible main, with the override its row carries. + let nested_main = named_node(doc, "Nested cached main"); + let nested = named_node(doc, "Nested internal instance"); + assert!(doc.is_remote(nested_main)); + assert!(matches!(doc.node(nested).unwrap().kind, NodeKind::Instance { component: linked, .. } if linked == nested_main)); + let external_main = named_node(doc, "Visible nested main"); + let external = doc + .node(component) + .unwrap() + .children + .iter() + .copied() + .find(|id| doc.node(*id).is_some_and(|n| n.name == "Nested cached instance")) + .unwrap(); + assert!(matches!(doc.node(external).unwrap().kind, NodeKind::Instance { component: linked, .. } if linked == external_main)); + let external_child = doc.node(external).unwrap().children[0]; + assert_eq!(doc.node(external_child).unwrap().source, Some(doc.node(external_main).unwrap().children[0])); + assert_eq!(fill_rgb_node(doc, external_child), Some([f64::from(0.2_f32), f64::from(0.7_f32), f64::from(0.1_f32)])); + }; + check(&doc); + let (saved, warnings) = crate::load_bytes(&crate::save_bytes(&doc, None).unwrap()).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + check(&saved); +} + +#[test] +fn visible_instance_override_retains_and_links_hidden_cached_main() { + let (doc, warnings) = import(&cached_component_override_fixture(), "Cached override.fig").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let nested = named_node(&doc, "Nested cached instance"); + let deep = named_node(&doc, "Deep cached instance"); + assert_eq!(doc.node(deep).unwrap().parent, Some(nested)); + let NodeKind::Instance { component: overridden, .. } = doc.node(deep).unwrap().kind else { panic!("nested-row override lost its instance") }; + let overridden = doc.node(overridden).unwrap(); + assert_eq!(overridden.name, "Overridden cached main"); + assert!(doc.is_remote(overridden.id)); + assert_eq!(doc.node(doc.node(deep).unwrap().children[0]).unwrap().source, Some(overridden.children[0])); +} + +#[test] +fn visible_component_default_swap_retains_hidden_cached_main() { + let (doc, warnings) = import(&cached_component_default_swap_fixture(), "Cached default swap.fig").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_default_swap(&doc); + let (saved, _) = crate::load_bytes(&crate::save_bytes(&doc, None).unwrap()).unwrap(); + assert_default_swap(&saved); +} + +#[test] +fn public_rest_import_ignores_private_fig_provenance() { + let value = json!({ + "name": "Public REST", + "document": {"type": "DOCUMENT", "children": [{"type": "CANVAS", "name": "Page", "children": []}]}, + "_figLibraryContexts": [{"type": "FRAME", "name": "Private cache", "children": [{"type": "COMPONENT", "name": "Must not import", "children": []}]}] + }); + let (doc, warnings) = crate::rest::import(&value, &|_| None).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(doc.library_store().is_none() && doc.nodes().all(|node| node.name != "Must not import")); + // Valid provenance on mapped layers links nothing either. + for message in [message(false), external_namespace_message()] { + let (json, _) = super::map::to_rest(&message, "Identity"); + let (doc, _) = crate::rest::import(&json, &|_| None).unwrap(); + assert!(doc.nodes().all(|n| n.source.is_none())); + } +} + +#[test] +fn the_last_change_of_a_guid_wins_and_removed_changes_delete() { + let moved = node(12, Some(11), "FRAME", "Moved frame", vec![]); + let mut removed = node(13, Some(10), "FRAME", "Removed frame", vec![]); + let Kv::Object(fields) = &mut removed else { panic!() }; + fields.iter_mut().find(|(key, _)| *key == "phase").unwrap().1 = Kv::Enum("REMOVED"); + let message = o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "DOCUMENT", "Document", vec![]), + node(2, Some(1), "CANVAS", "Page", vec![]), + node(10, Some(2), "FRAME", "Left", vec![]), + node(11, Some(2), "FRAME", "Right", vec![]), + node(12, Some(10), "FRAME", "Moved frame", vec![]), + node(13, Some(10), "FRAME", "Removed frame", vec![]), + moved, + removed, + ]), + )]); + let (doc, _) = import(&super::tests::canvas(&schema(), &message), "Changes.fig").unwrap(); + assert_eq!(doc.node(named_node(&doc, "Moved frame")).unwrap().parent, Some(named_node(&doc, "Right"))); + assert!(doc.nodes().all(|n| n.name != "Removed frame")); + assert!(doc.node(named_node(&doc, "Left")).unwrap().children.is_empty()); +} + +#[test] +fn internal_library_parent_cycles_are_bounded() { + let message = o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "CANVAS", "Visible page", vec![]), + node(2, Some(1), "INSTANCE", "Visible cyclic main", vec![("symbolData", symbol(11, vec![]))]), + node(3, Some(9), "CANVAS", "Cyclic library", vec![("internalOnly", Kv::Bool(true))]), + node(9, Some(11), "FRAME", "Loop frame", vec![]), + node(10, Some(3), "SYMBOL", "Unrelated component", vec![]), + node(11, Some(3), "SYMBOL", "Loop component", vec![]), + ]), + )]); + let (doc, warnings) = import(&super::tests::canvas(&schema(), &message), "Cyclic library.fig").unwrap(); + assert!(warnings.iter().any(|warning| warning.contains("deeper than")), "{warnings:?}"); + assert!(doc.node_count() < 1000, "the loop is cut at the nesting limit"); +} + +#[test] +fn parent_loops_through_the_internal_canvas_end() { + // The canvas is a child of a frame inside one of its own symbols, and the used main sits below + // that frame: the walk up from the main reaches the canvas again. + let message = o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "CANVAS", "Visible page", vec![]), + node(2, Some(1), "INSTANCE", "Uses the main", vec![("symbolData", symbol(12, vec![]))]), + node(3, Some(9), "CANVAS", "Loop library", vec![("internalOnly", Kv::Bool(true))]), + node(9, Some(11), "FRAME", "Loop frame", vec![]), + node(11, Some(3), "SYMBOL", "Loop symbol", vec![]), + node(12, Some(9), "SYMBOL", "Used main", vec![]), + ]), + )]); + let (doc, _) = import(&super::tests::canvas(&schema(), &message), "Loop.fig").unwrap(); + assert!(doc.nodes().any(|n| n.name == "Used main")); +} + +fn fill_rgb_node(doc: &Document, id: NodeId) -> Option<[f64; 3]> { + let node = doc.node(id)?; + match &node.fills.first()?.kind { + PaintKind::Solid { color } => Some([f64::from(color.r), f64::from(color.g), f64::from(color.b)]), + _ => None, + } +} + +fn message(swapped: bool) -> Kv<'static> { + let shape = |id, parent, x| node(id, Some(parent), "ROUNDED_RECTANGLE", "Same layer", vec![("transform", matrix(x))]); + let mut changes = vec![ + node(1, None, "DOCUMENT", "Identity", vec![]), + node(2, Some(1), "CANVAS", "Page", vec![]), + node(10, Some(2), "SYMBOL", "Renamed Left main", vec![]), + node(11, Some(10), "FRAME", "Frame", vec![("transform", matrix(2.0))]), + shape(12, 11, 2.0), + shape(13, 11, 4.0), + shape(14, 11, 6.0), + node(15, Some(11), "FRAME", "Deep frame", vec![]), + node(16, Some(15), "FRAME", "Deeper frame", vec![]), + shape(17, 16, 8.0), + node( + 20, + Some(2), + "SYMBOL", + "State=Right, Renamed=Yes", + vec![( + "componentPropDefs", + Kv::Array(vec![o(vec![ + ("id", g(200)), + ("name", s("Enabled")), + ("type", Kv::Enum("BOOL")), + ("initialValue", o(vec![("boolValue", Kv::Bool(true))])), + ])]), + )], + ), + node(21, Some(20), "FRAME", "Frame", vec![("transform", matrix(if swapped { 23.0 } else { 2.0 }))]), + shape(22, 21, 2.0), + shape(23, 21, 4.0), + shape(24, 21, 6.0), + node(30, Some(2), "SYMBOL", "Outer", vec![]), + node(40, Some(30), "INSTANCE", "Original wrapper", vec![("symbolData", symbol(50, vec![row(&[60, 21], vec![("transform", matrix(3.0))])]))]), + node(50, Some(2), "SYMBOL", "Renamed wrapper main", vec![]), + node( + 60, + Some(50), + "INSTANCE", + "Same nested slot", + vec![ + ("symbolData", symbol(20, vec![])), + ("visible", Kv::Bool(false)), + ("opacity", Kv::Float(0.3)), + ("mask", Kv::Bool(true)), + ("componentPropAssignments", Kv::Array(vec![o(vec![("defID", g(200)), ("value", o(vec![("boolValue", Kv::Bool(false))]))])])), + ], + ), + node(61, Some(50), "INSTANCE", "Same nested slot", vec![("symbolData", symbol(20, vec![]))]), + ]; + let mut nested = vec![ + ("name", s("Renamed nested slot")), + ("visible", Kv::Bool(true)), + ("opacity", Kv::Float(1.0)), + ("mask", Kv::Bool(false)), + ("componentPropAssignments", Kv::Array(vec![])), + ]; + if swapped { + nested.push(("overriddenSymbolID", g(10))); + } + changes.push(node( + 70, + Some(2), + "INSTANCE", + "Copy", + vec![( + "symbolData", + symbol( + 30, + vec![ + row(&[40], vec![("name", s("Renamed wrapper"))]), + row(&[40, 60], nested), + row(&[40, 60, if swapped { 11 } else { 21 }], vec![("transform", matrix(2.0))]), + ], + ), + )], + )); + o(vec![("nodeChanges", Kv::Array(changes))]) +} + +fn import_message(swapped: bool) -> Document { + let schema = Schema::parse(export::SCHEMA).unwrap(); + let bytes = super::tests::canvas(&schema, &message(swapped)); + let (doc, notes) = import(&bytes, "Identity.fig").unwrap(); + assert!(notes.is_empty(), "{notes:?}"); + doc +} + +fn named(doc: &Document, name: &str) -> NodeId { + doc.nodes().find(|n| n.name == name).unwrap().id +} + +fn child(doc: &Document, id: NodeId, index: usize) -> NodeId { + doc.node(id).unwrap().children[index] +} + +fn assert_contextual(doc: &Document) { + let outer = named(doc, "Outer"); + let right = named(doc, "State=Right, Renamed=Yes"); + let wrapper_main = named(doc, "Renamed wrapper main"); + let copy = named(doc, "Copy"); + let main_wrapper = child(doc, outer, 0); + let copy_wrapper = child(doc, copy, 0); + assert_eq!(doc.node(copy).unwrap().source, Some(outer)); + assert_eq!(doc.node(copy_wrapper).unwrap().source, Some(main_wrapper)); + assert!(matches!(&doc.node(copy_wrapper).unwrap().kind, NodeKind::Instance {component, ..} if *component == wrapper_main)); + for index in 0..2 { + let default_slot = child(doc, main_wrapper, index); + let copy_slot = child(doc, copy_wrapper, index); + assert_eq!(doc.node(copy_slot).unwrap().source, Some(default_slot), "slot {index}"); + assert!(matches!(&doc.node(default_slot).unwrap().kind, NodeKind::Instance {component, ..} if *component == right)); + assert!(matches!(&doc.node(copy_slot).unwrap().kind, NodeKind::Instance {component, ..} if *component == right)); + } + let default_slot = child(doc, main_wrapper, 0); + let copy_slot = child(doc, copy_wrapper, 0); + let inherited = child(doc, default_slot, 0); + let copy_frame = child(doc, copy_slot, 0); + assert!(matches!(doc.node(copy_frame).unwrap().kind, NodeKind::Frame)); + assert_eq!(doc.node(inherited).unwrap().transform.translation().x, 3.0); + assert_eq!(doc.node(copy_frame).unwrap().transform.translation().x, 2.0); + assert_eq!(doc.node(copy_frame).unwrap().source, Some(inherited)); + for index in 0..3 { + assert_eq!(doc.node(child(doc, copy_frame, index)).unwrap().source, Some(child(doc, inherited, index))); + assert_eq!(doc.node(child(doc, copy_frame, index)).unwrap().transform.translation().x, 2.0 + 2.0 * index as f64); + } + let a = doc.node(default_slot).unwrap(); + let b = doc.node(copy_slot).unwrap(); + assert!(!a.visible && a.is_mask && a.opacity < 1.0); + assert!(b.visible && !b.is_mask && b.opacity == 1.0); + assert!(matches!(&a.kind, NodeKind::Instance {props, ..} if props.len() == 1 && props.values().all(|p| p == &json!(false)))); + assert!(matches!(&b.kind, NodeKind::Instance {props, ..} if props.is_empty())); +} + +#[test] +fn contextual_sources_preserve_inherited_geometry_and_explicit_clears() { + assert_contextual(&import_message(false)); +} + +fn assert_swapped(doc: &Document) { + let outer = named(doc, "Outer"); + let right = named(doc, "State=Right, Renamed=Yes"); + let copy = named(doc, "Copy"); + let main_wrapper = child(doc, outer, 0); + let copy_wrapper = child(doc, copy, 0); + let copy_slot = child(doc, copy_wrapper, 0); + let default_slot = child(doc, main_wrapper, 0); + let left = named(doc, "Renamed Left main"); + assert_eq!(doc.node(copy).unwrap().source, Some(outer)); + assert_eq!(doc.node(copy_wrapper).unwrap().source, Some(main_wrapper)); + assert_eq!(doc.node(copy_slot).unwrap().source, Some(default_slot)); + assert!(matches!(&doc.node(copy_slot).unwrap().kind, NodeKind::Instance {component, props} if *component == left && props.is_empty())); + assert!(matches!(&doc.node(default_slot).unwrap().kind, NodeKind::Instance {component, ..} if *component == right)); + let current_frame = child(doc, copy_slot, 0); + let left_frame = child(doc, left, 0); + assert!(matches!(doc.node(current_frame).unwrap().kind, NodeKind::Frame)); + assert_eq!(doc.node(current_frame).unwrap().source, Some(left_frame)); + assert_eq!(doc.node(current_frame).unwrap().transform.translation().x, 2.0); + assert_eq!(doc.node(child(doc, right, 0)).unwrap().transform.translation().x, 23.0); + for index in 0..3 { + assert_eq!(doc.node(child(doc, current_frame, index)).unwrap().source, Some(child(doc, left_frame, index))); + assert_eq!(doc.node(child(doc, current_frame, index)).unwrap().transform.translation().x, 2.0 + 2.0 * index as f64); + } + let deep_copy = child(doc, child(doc, child(doc, current_frame, 3), 0), 0); + let deep_main = child(doc, child(doc, child(doc, left_frame, 3), 0), 0); + assert_eq!(doc.node(deep_copy).unwrap().source, Some(deep_main)); + assert_eq!(doc.node(deep_copy).unwrap().transform.translation().x, 8.0); + let other = child(doc, copy_wrapper, 1); + assert_eq!(doc.node(other).unwrap().source, Some(child(doc, main_wrapper, 1))); + assert!(matches!(&doc.node(other).unwrap().kind, NodeKind::Instance {component, ..} if *component == right)); + assert!(doc.node(copy_slot).unwrap().visible && !doc.node(copy_slot).unwrap().is_mask && doc.node(copy_slot).unwrap().opacity == 1.0); +} + +#[test] +fn swapped_children_use_current_main_through_frames_and_multiple_siblings() { + assert_swapped(&import_message(true)); +} + +fn edit_node(value: &mut Value, id: &str, edit: &mut dyn FnMut(&mut Value)) { + if value.get("id").and_then(Value::as_str) == Some(id) { + edit(value); + } + if let Some(kids) = value.get_mut("children").and_then(Value::as_array_mut) { + for child in kids { + edit_node(child, id, edit); + } + } +} + +#[test] +fn missing_foreign_self_and_wrong_namespace_provenance_is_not_guessed() { + for (origin, id) in [("1:99999", "I1:70;1:40"), ("1:12", "I1:70;1:40"), ("1:70", "1:70"), ("1:40", "I1:70;1:40")] { + let (mut json, _) = super::map::to_rest(&message(false), "Identity"); + edit_node(&mut json["document"]["children"][0], id, &mut |node| { + node[crate::rest::FIG_SOURCE_FIELD]["origin"] = json!(origin); + if id == "1:70" { + node["componentId"] = json!("1:70"); // a root cannot be its own component + } else if origin == "1:40" { + node["id"] = json!("I1:999;1:40"); + } else { + node["id"] = json!(format!("I1:70;{origin}")); // valid namespace, but missing/foreign target + } + }); + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + let target = if id == "1:70" { named(&doc, "Copy") } else { named(&doc, "Renamed wrapper") }; + assert!(doc.node(target).unwrap().source.is_none(), "{origin}/{id}"); + } +} + +#[test] +fn forward_main_references_and_duplicate_ids_are_conservative() { + let (mut json, _) = super::map::to_rest(&message(false), "Identity"); + json["document"]["children"][0]["children"].as_array_mut().unwrap().rotate_right(1); + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + assert!(doc.node(named(&doc, "Copy")).unwrap().source.is_some()); + // 1:40 is nested inside Outer, so duplicate that actual template at page level. + let template = json["document"]["children"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == "1:30").unwrap()["children"][0].clone(); + json["document"]["children"][0]["children"].as_array_mut().unwrap().push(template); + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + assert!(doc.node(named(&doc, "Renamed wrapper")).unwrap().source.is_none()); +} + +#[test] +fn missing_contextual_targets_do_not_fall_back_to_a_global_slot() { + let (mut json, _) = super::map::to_rest(&message(false), "Identity"); + edit_node(&mut json["document"]["children"][0], "1:40", &mut |node| { + node["children"].as_array_mut().unwrap().retain(|n| n["id"] != "I1:40;1:60"); + }); + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + let copy_slot = child(&doc, child(&doc, named(&doc, "Copy"), 0), 0); + assert!(doc.node(copy_slot).unwrap().source.is_none()); + assert!(doc.nodes().any(|n| n.name == "Same nested slot"), "the raw slot still exists"); +} + +#[test] +fn instance_to_empty_frame_keeps_its_source_identity() { + let mut msg = message(false); + let Kv::Object(top) = &mut msg else { panic!() }; + let Kv::Array(nodes) = &mut top.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap().1 else { panic!() }; + let copy = nodes.iter_mut().find(|n| n.get("name").and_then(Kv::str) == Some("Copy")).unwrap(); + let Kv::Object(fields) = copy else { panic!() }; + let Kv::Object(symbol) = &mut fields.iter_mut().find(|(key, _)| *key == "symbolData").unwrap().1 else { panic!() }; + let Kv::Array(overrides) = &mut symbol.iter_mut().find(|(key, _)| *key == "symbolOverrides").unwrap().1 else { panic!() }; + overrides.push(row(&[40, 61], vec![("type", Kv::Enum("FRAME"))])); + let (doc, _) = import(&super::tests::canvas(&schema(), &msg), "Identity.fig").unwrap(); + let main = child(&doc, named(&doc, "Outer"), 0); + let copy = child(&doc, named(&doc, "Copy"), 0); + let frame = child(&doc, copy, 1); + assert!(matches!(doc.node(frame).unwrap().kind, NodeKind::Frame)); + assert!(doc.node(frame).unwrap().children.is_empty()); + assert_eq!(doc.node(frame).unwrap().source, Some(child(&doc, main, 1))); +} + +#[test] +fn explicit_frame_to_instance_resets_children_to_the_resolved_main() { + let mut msg = message(false); + let Kv::Object(top) = &mut msg else { panic!() }; + let Kv::Array(nodes) = &mut top.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap().1 else { panic!() }; + nodes.push(node(80, Some(30), "FRAME", "Original frame", vec![])); + nodes.push(node(81, Some(80), "ROUNDED_RECTANGLE", "Old frame child", vec![])); + let copy = nodes.iter_mut().find(|n| n.get("name").and_then(Kv::str) == Some("Copy")).unwrap(); + let Kv::Object(fields) = copy else { panic!() }; + let Kv::Object(symbol) = &mut fields.iter_mut().find(|(key, _)| *key == "symbolData").unwrap().1 else { panic!() }; + let Kv::Array(overrides) = &mut symbol.iter_mut().find(|(key, _)| *key == "symbolOverrides").unwrap().1 else { panic!() }; + overrides.push(row(&[80], vec![("type", Kv::Enum("INSTANCE")), ("overriddenSymbolID", g(10))])); + let (doc, notes) = import(&super::tests::canvas(&schema(), &msg), "Identity.fig").unwrap(); + assert!(notes.is_empty(), "{notes:?}"); + let original = child(&doc, named(&doc, "Outer"), 1); + let current = child(&doc, named(&doc, "Copy"), 1); + let left = named(&doc, "Renamed Left main"); + assert!(matches!(doc.node(original).unwrap().kind, NodeKind::Frame)); + assert_eq!(doc.node(current).unwrap().source, Some(original)); + assert!(matches!(&doc.node(current).unwrap().kind, NodeKind::Instance {component, ..} if *component == left)); + assert_eq!(doc.node(child(&doc, current, 0)).unwrap().source, Some(child(&doc, left, 0))); +} + +// The orphan symbol is decoded and expanded but is not a layer on any imported page. This +// matches an external main: its instance becomes a frame while the rendered body survives. +fn external_namespace_message() -> Kv<'static> { + o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "DOCUMENT", "External namespace", vec![]), + node(2, Some(1), "CANVAS", "Page", vec![]), + node( + 10, + Some(2), + "SYMBOL", + "Known main", + vec![( + "componentPropDefs", + Kv::Array(vec![o(vec![ + ("id", g(200)), + ("name", s("Enabled")), + ("type", Kv::Enum("BOOL")), + ("initialValue", o(vec![("boolValue", Kv::Bool(true))])), + ])]), + )], + ), + node(11, Some(10), "FRAME", "Known frame", vec![("transform", matrix(2.0))]), + node(12, Some(11), "ROUNDED_RECTANGLE", "Known leaf", vec![("transform", matrix(4.0))]), + node(30, Some(2), "SYMBOL", "Outer external", vec![]), + node( + 90, + Some(30), + "INSTANCE", + "External body", + vec![("symbolData", symbol(99, vec![row(&[110, 11], vec![("transform", matrix(3.0))])]))], + ), + node(99, None, "SYMBOL", "External orphan", vec![]), + node(100, Some(99), "FRAME", "Body frame", vec![]), + node(101, Some(99), "FRAME", "Other body frame", vec![]), + node( + 110, + Some(100), + "INSTANCE", + "Same slot", + vec![ + ("symbolData", symbol(10, vec![])), + ("visible", Kv::Bool(false)), + ("opacity", Kv::Float(0.3)), + ("mask", Kv::Bool(true)), + ("componentPropAssignments", Kv::Array(vec![o(vec![("defID", g(200)), ("value", o(vec![("boolValue", Kv::Bool(false))]))])])), + ], + ), + node(111, Some(100), "INSTANCE", "Same slot", vec![("symbolData", symbol(10, vec![]))]), + node( + 70, + Some(2), + "INSTANCE", + "External copy", + vec![( + "symbolData", + symbol( + 30, + vec![ + row( + &[90, 110], + vec![ + ("name", s("Renamed owned slot")), + ("visible", Kv::Bool(true)), + ("opacity", Kv::Float(1.0)), + ("mask", Kv::Bool(false)), + ("componentPropAssignments", Kv::Array(vec![])), + ], + ), + row(&[90, 110, 11], vec![("transform", matrix(2.0))]), + ], + ), + )], + ), + ]), + )]) +} + +fn assert_external_namespace(doc: &Document) { + let outer = named(doc, "Outer external"); + let copy = named(doc, "External copy"); + let main = named(doc, "Known main"); + let body = child(doc, outer, 0); + let copied_body = child(doc, copy, 0); + assert!(matches!(doc.node(body).unwrap().kind, NodeKind::Frame)); + assert!(matches!(doc.node(copied_body).unwrap().kind, NodeKind::Frame)); + assert_eq!(doc.node(body).unwrap().source, None, "the absent external main must not acquire a guessed native node"); + assert_eq!(doc.node(copied_body).unwrap().source, Some(body)); + let frame = child(doc, body, 0); + let copied_frame = child(doc, copied_body, 0); + assert_eq!(doc.node(frame).unwrap().source, None, "materialized ordinary layers are owned by the frame"); + assert_eq!(doc.node(copied_frame).unwrap().source, Some(frame)); + for index in 0..2 { + let slot = child(doc, frame, index); + let copied_slot = child(doc, copied_frame, index); + assert_eq!(doc.node(slot).unwrap().source, Some(main), "known owned instance has its real root main"); + assert_eq!(doc.node(copied_slot).unwrap().source, Some(slot), "copied slots preserve the contextual owned layer identity"); + assert!(matches!(&doc.node(slot).unwrap().kind, NodeKind::Instance {component, ..} if *component == main)); + assert!(matches!(&doc.node(copied_slot).unwrap().kind, NodeKind::Instance {component, ..} if *component == main)); + let inherited = child(doc, slot, 0); + let current = child(doc, copied_slot, 0); + assert_eq!(doc.node(inherited).unwrap().source, Some(child(doc, main, 0))); + assert_eq!(doc.node(current).unwrap().source, Some(inherited)); + assert_eq!(doc.node(inherited).unwrap().transform.translation().x, if index == 0 { 3.0 } else { 2.0 }); + assert_eq!(doc.node(current).unwrap().transform.translation().x, 2.0); + assert_eq!(doc.node(child(doc, current, 0)).unwrap().source, Some(child(doc, inherited, 0))); + assert_eq!(doc.node(child(doc, current, 0)).unwrap().transform.translation().x, 4.0); + } + let old = doc.node(child(doc, frame, 0)).unwrap(); + let current = doc.node(child(doc, copied_frame, 0)).unwrap(); + assert!(!old.visible && old.is_mask && old.opacity < 1.0); + assert!(current.visible && !current.is_mask && current.opacity == 1.0); + assert_eq!(current.name, "Renamed owned slot"); + assert!(matches!(&old.kind, NodeKind::Instance {props, ..} if props.len() == 1)); + assert!(matches!(¤t.kind, NodeKind::Instance {props, ..} if props.is_empty())); +} + +#[test] +fn absent_external_mains_keep_known_owned_instances_and_contextual_copies() { + let (doc, notes) = import(&super::tests::canvas(&schema(), &external_namespace_message()), "External.fig").unwrap(); + assert!(notes.iter().any(|w| w.contains("components that aren't in the file")), "{notes:?}"); + assert!(!notes.iter().any(|w| w.contains("source link")), "materialized own layers are not unresolved copies: {notes:?}"); + assert_external_namespace(&doc); +} + +#[test] +fn external_namespace_is_private_and_requires_actual_wire_instances() { + for mode in 0..3 { + let (mut json, _) = super::map::to_rest(&external_namespace_message(), "External"); + if mode == 0 { + let (doc, _) = crate::rest::import(&json, &|_| None).unwrap(); + assert!(doc.nodes().all(|n| n.source.is_none())); + continue; + } + edit_node(&mut json["document"]["children"][0], "1:90", &mut |node| { + if mode == 1 { + node["type"] = json!("FRAME"); + } else { + node[crate::rest::FIG_SOURCE_FIELD]["wireInstance"] = json!(false); + node[crate::rest::FIG_SOURCE_FIELD]["wireFrame"] = json!(true); + } + }); + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + let raw_slot = child(&doc, child(&doc, child(&doc, named(&doc, "Outer external"), 0), 0), 0); + assert!(doc.node(raw_slot).unwrap().source.is_none(), "ordinary/fake frame mode {mode} must not establish a namespace"); + } +} + +#[test] +fn external_namespace_does_not_cover_present_wrong_kind_or_blocked_main() { + for mode in 0..3 { + let (mut json, _) = super::map::to_rest(&external_namespace_message(), "External"); + let page = &mut json["document"]["children"][0]; + match mode { + 0 => page["children"].as_array_mut().unwrap().push(json!({"id":"1:99", "type":"FRAME", "name":"Wrong kind", + "_figSource":{"origin":"1:99","expanded":false,"wireFrame":true,"wireInstance":false}})), + 1 => edit_node(page, "1:90", &mut |node| { + node["componentId"] = json!("1:30"); + }), + _ => page["children"].as_array_mut().unwrap().push(json!({"id":"1:99", "type":"COMPONENT", "name":"Cycle main", + "_figSource":{"origin":"1:99","expanded":false,"wireFrame":false,"wireInstance":false}, + "children":[{"id":"1:199", "type":"INSTANCE", "name":"Cycle edge", "componentId":"1:30", + "_figSource":{"origin":"1:199","expanded":false,"wireFrame":false,"wireInstance":true}}]})), + } + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + let body = child(&doc, named(&doc, "Outer external"), 0); + assert!(matches!(doc.node(body).unwrap().kind, NodeKind::Frame)); + let slot = child(&doc, child(&doc, body, 0), 0); + assert!(doc.node(slot).unwrap().source.is_none(), "existing invalid/cycle/self main mode {mode} must not become a materialized namespace"); + } +} + +#[test] +fn external_namespace_rejects_foreign_siblings_duplicates_and_bad_paths() { + for mode in 0..4 { + let (mut json, _) = super::map::to_rest(&external_namespace_message(), "External"); + let page = &mut json["document"]["children"][0]; + match mode { + 0 => { + // Same root namespace and GUID, but physically under a different frame slot. + let mut moved = None; + edit_node(page, "I1:70;1:90;1:100", &mut |node| { + moved = Some(node["children"].as_array_mut().unwrap().remove(0)); + }); + edit_node(page, "I1:70;1:90;1:101", &mut |node| { + node["children"] = json!([moved.take().unwrap()]); + }); + } + 1 => edit_node(page, "I1:70;1:90;1:110", &mut |node| { + node["id"] = json!("I1:70;1:999;1:110"); + }), + 2 => { + let duplicate = page["children"].as_array().unwrap().iter().find(|n| n["id"] == "1:30").unwrap()["children"][0].clone(); + page["children"].as_array_mut().unwrap().push(duplicate); + } + _ => edit_node(page, "I1:90;1:100", &mut |node| { + node["id"] = json!("I1:999;1:100"); + }), + } + let (doc, warnings) = crate::rest::import_fig(&json, &|_| None).unwrap(); + let slot = named(&doc, "Renamed owned slot"); + assert!(doc.node(slot).unwrap().source.is_none(), "invalid frame namespace mode {mode}"); + assert!(warnings.iter().any(|w| w.contains("source link")), "invalid expanded paths remain reported: {warnings:?}"); + } +} diff --git a/crates/format/src/rest/fig_sources.rs b/crates/format/src/rest/fig_sources.rs new file mode 100644 index 00000000..61097e52 --- /dev/null +++ b/crates/format/src/rest/fig_sources.rs @@ -0,0 +1,316 @@ +//! Explicit .fig GUID provenance projected through instance expansion contexts. +//! All strings borrow the bounded JSON; no names, node-kind similarity or child order match links. + +use std::collections::{BTreeMap, BTreeSet}; + +use figcraft_doc::{Document, NodeId, NodeKind}; +use serde_json::Value; + +use super::{FIG_SOURCE_FIELD, MAX_DEPTH}; + +// A GUID has at most two 20-digit u64 parts and a colon; instance paths contain at most +// MAX_DEPTH + 2 GUIDs. Check before constructing any projected path string. +const MAX_SOURCE_ID_BYTES: usize = 44 * (MAX_DEPTH + 2); + +#[derive(Clone, Copy)] +struct Origin<'a> { + rest: &'a str, + guid: &'a str, + expanded: bool, + wire_frame: bool, + wire_instance: bool, + component: Option<&'a str>, +} + +pub(super) struct Sources<'a> { + /// Most layers recorded. + max_nodes: usize, + nodes: BTreeMap>, + duplicate_ids: BTreeSet<&'a str>, + rejected: usize, +} + +fn guid(s: &str) -> bool { + let Some((session, local)) = s.split_once(':') else { return false }; + s.len() <= 41 + && [session, local].into_iter().all(|part| !part.is_empty() && part.bytes().all(|c| c.is_ascii_digit()) && part.parse::().is_ok()) +} + +fn expanded_id(instance: &str, origin: &str) -> Option { + let bytes = instance.len().checked_add(origin.len())?.checked_add(2)?; + if bytes > MAX_SOURCE_ID_BYTES { + return None; + } + Some(if instance.starts_with('I') { format!("{instance};{origin}") } else { format!("I{instance};{origin}") }) +} + +impl<'a> Sources<'a> { + pub(super) fn new(max_nodes: usize) -> Self { + Self { max_nodes, nodes: BTreeMap::new(), duplicate_ids: BTreeSet::new(), rejected: 0 } + } + + pub(super) fn record(&mut self, id: NodeId, node: &'a Value, primary: bool) { + let Some(rest) = node.get("id").and_then(Value::as_str) else { return }; + if !primary { + self.duplicate_ids.insert(rest); + return; + } + let Some(meta) = node.get(FIG_SOURCE_FIELD) else { return }; + let parsed = meta.get("origin").and_then(Value::as_str).zip(meta.get("expanded").and_then(Value::as_bool)); + let Some((origin, expanded)) = parsed else { + self.rejected += 1; + return; + }; + if self.nodes.len() >= self.max_nodes + || rest.len() > MAX_SOURCE_ID_BYTES + || !guid(origin) + || (!expanded && rest != origin) + || (expanded && !rest.starts_with('I')) + { + self.rejected += 1; + return; + } + let wire_frame = meta.get("wireFrame").and_then(Value::as_bool).unwrap_or(false); + let wire_instance = !wire_frame + && meta.get("wireInstance").and_then(Value::as_bool) == Some(true) + && node.get("type").and_then(Value::as_str) == Some("INSTANCE"); + let component = wire_instance.then(|| node.get("componentId").and_then(Value::as_str).filter(|id| guid(id))).flatten(); + self.nodes.insert(id, Origin { rest, guid: origin, expanded, wire_frame, wire_instance, component }); + } + + fn origin(&self, id: NodeId) -> Option> { + let meta = *self.nodes.get(&id)?; + (!self.duplicate_ids.contains(meta.rest)).then_some(meta) + } + + fn raw_in_main(&self, origin: &str, main: NodeId, doc: &Document, ids: &BTreeMap) -> Option { + let target = *ids.get(origin)?; + let meta = self.origin(target)?; + (!meta.expanded && meta.rest == origin && target != main && doc.is_ancestor(main, target)).then_some(target) + } + + fn root_source(&self, id: NodeId, doc: &Document) -> Option { + let NodeKind::Instance { component, .. } = &doc.node(id)?.kind else { return None }; + let main_meta = self.origin(*component)?; + (!main_meta.expanded + && matches!(doc.node(*component)?.kind, NodeKind::Component { .. }) + && *component != id + && !doc.is_ancestor(id, *component) + && !doc.is_ancestor(*component, id)) + .then_some(*component) + } + + fn missing_frame(&self, id: NodeId, doc: &Document, ids: &BTreeMap) -> bool { + self.origin(id).is_some_and(|meta| { + meta.wire_instance + && matches!(doc.node(id).map(|n| &n.kind), Some(NodeKind::Frame)) + // An existing target of the wrong kind or a cycle-blocked main is not missing. + && meta.component.is_some_and(|main| !ids.contains_key(main)) + }) + } + + fn owner(&self, id: NodeId, doc: &Document, ids: &BTreeMap) -> Option { + let mut current = doc.node(id)?.parent; + let mut seen = BTreeSet::from([id]); + for _ in 0..512 { + let parent = current?; + if !seen.insert(parent) { + return None; + } + let node = doc.node(parent)?; + if matches!(node.kind, NodeKind::Instance { .. }) || self.missing_frame(parent, doc, ids) { + return Some(parent); + } + current = node.parent; + } + None + } + + fn namespace(&self, id: NodeId, owner: NodeId, doc: &Document) -> bool { + let Some(owner_meta) = self.origin(owner) else { return false }; + let mut current = id; + let mut seen = BTreeSet::new(); + for _ in 0..512 { + if current == owner { + return true; + } + if !seen.insert(current) { + return false; + } + let Some(meta) = self.origin(current) else { return false }; + if !meta.expanded || expanded_id(owner_meta.rest, meta.guid).as_deref() != Some(meta.rest) { + return false; + } + let Some(parent) = doc.node(current).and_then(|n| n.parent) else { return false }; + current = parent; + } + false + } + + // A missing-main instance is materialized as a frame. Outside real instances its children + // are owned layers; known instances there become ordinary roots with their own actual main. + fn owned_layer(&self, id: NodeId, doc: &Document, ids: &BTreeMap) -> bool { + if doc.ancestors(id).into_iter().any(|p| doc.node(p).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. }))) { + return false; + } + let mut current = id; + for _ in 0..=MAX_DEPTH { + let Some(owner) = self.owner(current, doc, ids) else { return false }; + if !self.missing_frame(owner, doc, ids) || !self.namespace(current, owner, doc) { + return false; + } + let Some(meta) = self.origin(owner) else { return false }; + if !meta.expanded { + return true; + } + current = owner; + } + false + } + + fn frame_target(&self, id: NodeId, owner: NodeId, source_owner: NodeId, doc: &Document, ids: &BTreeMap) -> Option { + let meta = self.origin(id)?; + let owner_meta = self.origin(owner)?; + let source_meta = self.origin(source_owner)?; + if !self.missing_frame(source_owner, doc, ids) || owner_meta.component != source_meta.component { + return None; + } + let target = *ids.get(expanded_id(source_meta.rest, meta.guid)?.as_str())?; + let target_meta = self.origin(target)?; + if !target_meta.expanded + || target_meta.guid != meta.guid + || self.owner(target, doc, ids) != Some(source_owner) + || !self.namespace(target, source_owner, doc) + { + return None; + } + let parent = doc.node(id)?.parent?; + let target_parent = doc.node(target)?.parent?; + if parent == owner { + (target_parent == source_owner).then_some(target) + } else { + (target_parent != source_owner && self.origin(parent)?.guid == self.origin(target_parent)?.guid).then_some(target) + } + } + + fn source( + &self, + id: NodeId, + doc: &Document, + ids: &BTreeMap, + memo: &mut BTreeMap>, + active: &mut BTreeSet, + depth: usize, + ) -> Option { + if let Some(source) = memo.get(&id) { + return *source; + } + if depth > MAX_DEPTH || !active.insert(id) { + return None; + } + let found = (|| { + let meta = self.origin(id)?; + let _ = doc.node(id)?; + if !meta.expanded { + return self.root_source(id, doc); + } + // An actual wire INSTANCE whose main is absent remains a namespace owner even + // when the REST importer materializes it as a frame. Ordinary frames never do. + let owner = self.owner(id, doc, ids)?; + if !self.namespace(id, owner, doc) { + return None; + } + let source_owner = self.source(owner, doc, ids, memo, active, depth + 1); + if self.missing_frame(owner, doc, ids) { + let target = match source_owner { + Some(source_owner) => self.frame_target(id, owner, source_owner, doc, ids)?, + None if self.owned_layer(id, doc, ids) => return self.root_source(id, doc), + _ => return None, + }; + return (target != id && !doc.is_ancestor(id, target)).then_some(target); + } + let NodeKind::Instance { component: main, .. } = &doc.node(owner)?.kind else { return None }; + let source_owner = source_owner?; + let target = match &doc.node(source_owner)?.kind { + NodeKind::Component { .. } if source_owner == *main => self.raw_in_main(meta.guid, *main, doc, ids)?, + NodeKind::Instance { component: source_main, .. } if source_main != main => { + // A resolved main switch is evidence for resetting to that CURRENT main. + self.raw_in_main(meta.guid, *main, doc, ids)? + } + NodeKind::Instance { component: source_main, .. } if source_main == main => { + let source_meta = self.origin(source_owner)?; + let target = *ids.get(expanded_id(source_meta.rest, meta.guid)?.as_str())?; + let target_meta = self.origin(target)?; + let target_owner = self.owner(target, doc, ids); + if !target_meta.expanded || target_meta.guid != meta.guid || target_owner != Some(source_owner) { + return None; + } + target + } + NodeKind::Frame if self.origin(source_owner)?.wire_frame => { + // An actual wire FRAME becoming INSTANCE has no previous main. Its resolved + // current main is the only proven namespace for its newly expanded children. + self.raw_in_main(meta.guid, *main, doc, ids)? + } + _ => return None, + }; + (target != id && !doc.is_ancestor(id, target)).then_some(target) + })(); + active.remove(&id); + memo.insert(id, found); + found + } + + pub(super) fn apply(self, doc: &mut Document, ids: &BTreeMap) -> usize { + let mut links = BTreeMap::new(); + let mut active = BTreeSet::new(); + for &id in self.nodes.keys() { + let _ = self.source(id, doc, ids, &mut links, &mut active, 0); + } + let mut missing = self.rejected; + for (&id, meta) in &self.nodes { + let needs_source = + (meta.expanded && !self.owned_layer(id, doc, ids)) || doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. })); + if !needs_source { + continue; + } + let target = links.get(&id).copied().flatten().filter(|_| acyclic(id, &links)); + if let Some(target) = target { + if let Some(node) = doc.node_mut(id) { + node.source = Some(target); + } + } else { + missing += 1; + } + } + missing + } +} + +fn acyclic(id: NodeId, links: &BTreeMap>) -> bool { + let mut current = id; + for _ in 0..=MAX_DEPTH { + let Some(next) = links.get(¤t).copied().flatten() else { return true }; + if next == id { + return false; + } + current = next; + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn self_cycles_and_overlong_source_chains_are_rejected() { + let self_link = BTreeMap::from([(NodeId(1), Some(NodeId(1)))]); + assert!(!acyclic(NodeId(1), &self_link)); + let cycle = BTreeMap::from([(NodeId(1), Some(NodeId(2))), (NodeId(2), Some(NodeId(1)))]); + assert!(!acyclic(NodeId(1), &cycle)); + let long = (1..=MAX_DEPTH as u64 + 2).map(|i| (NodeId(i), Some(NodeId(i + 1)))).collect(); + assert!(!acyclic(NodeId(1), &long)); + assert!(expanded_id(&"x".repeat(MAX_SOURCE_ID_BYTES), "1:2").is_none()); + } +} diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 798b2645..8c41ffb3 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -16,7 +16,8 @@ //! //! Instances come out with their own layers as the file lists them. Linking those layers to the //! main component's (`source`) and deriving overrides is left to the caller (the engine does it -//! after loading), since that needs the component model. +//! after loading), since that needs the component model. The private .fig entry point additionally +//! preserves explicit GUID/expansion provenance before the engine derives overrides. #[cfg(test)] mod blur_tests; @@ -24,6 +25,7 @@ mod blur_tests; mod conditional_tests; #[cfg(test)] mod effects_tests; +mod fig_sources; #[cfg(test)] mod gamepad_tests; pub(crate) mod props; @@ -53,6 +55,10 @@ pub use variables::export as export_variables; pub const MAX_NODES: usize = 200_000; /// Deepest layer nesting followed. const MAX_DEPTH: usize = 256; +/// Private .fig provenance, consumed only by `import_fig`. +pub(crate) const FIG_SOURCE_FIELD: &str = "_figSource"; +/// Hidden .fig library context; accepted only by [`import_fig`]. +pub(crate) const FIG_LIBRARY_CONTEXTS_FIELD: &str = "_figLibraryContexts"; /// Longest SVG path string read from one geometry entry. const MAX_PATH_BYTES: usize = 4 << 20; /// Most path elements in one vector. @@ -138,12 +144,22 @@ pub fn import(v: &Value, images: ImageResolver) -> Result<(Document, Vec /// in the file JSON (a `meta` with `variableCollections`, or `variableCollections` and /// `variables` at the top level) are read too; the first definition of an id wins. pub fn import_with(v: &Value, variables: Option<&Value>, images: ImageResolver) -> Result<(Document, Vec), FormatError> { + import_impl(v, variables, images, false) +} + +/// Internal .fig JSON path: GUID provenance is not part of the public REST import contract. +pub(crate) fn import_fig(v: &Value, images: ImageResolver) -> Result<(Document, Vec), FormatError> { + import_impl(v, None, images, true) +} + +fn import_impl(v: &Value, variables: Option<&Value>, images: ImageResolver, fig_sources: bool) -> Result<(Document, Vec), FormatError> { let entries: Vec<&Value> = if is_document(v) { vec![v] } else { nodes_entries(v).collect() }; if entries.is_empty() { return Err(FormatError::Corrupt("not a REST API file JSON (no `document`)".into())); } let name = text(v, "name").filter(|n| !n.trim().is_empty()).map_or("Imported file", clip_str); let mut cx = Ctx::new(Document::new(name), images); + cx.fig_sources = fig_sources.then(|| fig_sources::Sources::new(MAX_NODES)); let blank = cx.doc.first_page(); let mut loose: Option = None; for e in &entries { @@ -178,6 +194,12 @@ pub fn import_with(v: &Value, variables: Option<&Value>, images: ImageResolver) None => cx.warn("entries without a node type were skipped"), } } + if fig_sources { + for context in v.get(FIG_LIBRARY_CONTEXTS_FIELD).and_then(Value::as_array).into_iter().flatten() { + let store = cx.doc.ensure_library_store(); + cx.build(context, store, Space::default(), None, 0, false); + } + } // Drop the empty page the new document started with once real pages exist. if let Some(p) = blank && cx.doc.pages.len() > 1 @@ -291,6 +313,7 @@ pub(crate) struct Ctx<'a> { count: usize, /// REST node id → imported node. ids: BTreeMap, + fig_sources: Option>, component_meta: BTreeMap<&'a str, &'a Value>, set_meta: BTreeMap<&'a str, &'a Value>, style_meta: BTreeMap<&'a str, &'a Value>, @@ -321,6 +344,7 @@ impl<'a> Ctx<'a> { warnings: Warnings::default(), count: 0, ids: BTreeMap::new(), + fig_sources: None, component_meta: BTreeMap::new(), set_meta: BTreeMap::new(), style_meta: BTreeMap::new(), @@ -411,7 +435,10 @@ impl<'a> Ctx<'a> { } self.count += 1; if let Some(rid) = text(j, "id") { - self.ids.entry(rid.to_string()).or_insert(id); + let primary = *self.ids.entry(rid.to_string()).or_insert(id) == id; + if let Some(sources) = &mut self.fig_sources { + sources.record(id, j, primary); + } } if ty == "INSTANCE" { self.instances.push((id, j)); @@ -822,6 +849,12 @@ impl<'a> Ctx<'a> { } } self.resolve_instances(); + if let Some(sources) = self.fig_sources.take() { + let missing = sources.apply(&mut self.doc, &self.ids); + if missing > 0 { + self.warn(format!("{missing} .fig instance source link(s) could not be resolved and were left unlinked")); + } + } self.rename_props(); for (id, j) in std::mem::take(&mut self.protos) { let list = self.interactions(j); From 4b29d8c34de4241b6b4c9cb93a1188e8bec021bd Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 01:39:47 +0200 Subject: [PATCH 009/118] M1: apply instance overrides by key and order, settle bound paints, keep stroke outline styles Override rows of an instance can name a layer of its main by the key the layer had before the main was edited. The key now resolves to the layer's current id when it is unambiguous and the current id does not exist already. Rows of an enclosing instance win over those of the instances nested in it, whatever order the wire lists them in, and an explicit "no style" on an override detaches the style instead of falling back to the older field. Paints bound to variables take the colours of the active modes when a document loads, before the first edit, without touching other pending changes. A stroke-only vector that imports as the outline of its stroke now moves the stroke's shared style and colour variables to the fill it becomes, and vectors whose single segments carry their own stroke style say so in a warning. --- crates/engine/src/cmd/file.rs | 2 + crates/engine/src/cmd/styles.rs | 21 ++ crates/engine/src/lib.rs | 2 + crates/engine/src/variable_tests.rs | 70 +++++++ crates/format/src/fig/map.rs | 164 +++++++++++++--- crates/format/src/fig/network.rs | 14 +- .../format/src/fig/source_identity_tests.rs | 182 ++++++++++++++++++ crates/format/src/fig/tail_tests.rs | 19 ++ .../src/rest/import_regression_tests.rs | 59 ++++++ crates/format/src/rest/mod.rs | 56 ++++-- docs/file-format-parity.md | 2 +- 11 files changed, 548 insertions(+), 43 deletions(-) create mode 100644 crates/format/src/rest/import_regression_tests.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f50c5edc..f9175b9c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -520,6 +520,7 @@ fn load_with( if loaded.doc.name.is_empty() { loaded.doc.name = file_stem(name_hint); } + super::styles::apply_paint_bindings_on_load(&mut loaded.doc); Ok((loaded, false)) } } @@ -529,6 +530,7 @@ fn load_with( /// innermost first so outer instances settle last. fn link_instances(doc: &mut Document) { crate::style_vars::after_load(doc); + super::styles::apply_paint_bindings_on_load(doc); let mut insts: Vec<(usize, NodeId)> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })).map(|n| (doc.ancestors(n.id).len(), n.id)).collect(); insts.sort_by_key(|a| std::cmp::Reverse(a.0)); diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 552a44cf..25e36e3a 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -913,6 +913,24 @@ pub fn apply_bindings(doc: &mut Document) { /// [`apply_bindings`] for the layers `only` (every bound layer when `None`). pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTreeSet>) { + apply_bindings_matching(doc, only, |_| true); +} + +/// Settle imported paint colours before the document is first shown. Other bindings can affect +/// layout or component structure, which must be resolved through the normal edit pipeline. +pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) { + let pending = doc.take_dirty(); + apply_bindings_matching(doc, None, |field| { + super::lists::list_binding(field).is_some_and(|(list, _, prop)| { + matches!(list, super::lists::BoundList::Fills | super::lists::BoundList::Strokes) + && (prop == "color" || super::lists::stop_binding(prop).is_some()) + }) + }); + doc.take_dirty(); + doc.mark_dirty(pending); +} + +fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::BTreeSet>, include: impl Fn(&str) -> bool) { use super::lists::{BoundList, list_binding}; if doc.variables.variables.is_empty() { return; @@ -932,6 +950,9 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr let mut grid_sets: Vec<(usize, String, f64)> = Vec::new(); let mut prop_sets: Vec<(String, VariableValue)> = Vec::new(); for (field, var) in binds { + if !include(&field) { + continue; + } let Some(val) = doc.variables.resolve(&var, &modes) else { continue }; // One list item's value: a paint's colour, an effect's or a layout grid's field. if let Some((list, i, prop)) = list_binding(&field) { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..6e0e8b12 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -401,6 +401,8 @@ impl DocState { doc.repair(); } doc.link_layer_text_styles(); + cmd::styles::apply_paint_bindings_on_load(&mut doc); + // Initial import settlement belongs to the saved baseline, not the user's first edit. let page = doc.current_page.filter(|p| doc.pages.contains(p)).or_else(|| doc.first_page()).unwrap_or(NodeId(0)); let doc = Arc::new(doc); let mut st = Self { diff --git a/crates/engine/src/variable_tests.rs b/crates/engine/src/variable_tests.rs index 536b2699..f5808b2c 100644 --- a/crates/engine/src/variable_tests.rs +++ b/crates/engine/src/variable_tests.rs @@ -5,6 +5,76 @@ use serde_json::json; use crate::Session; use crate::params::id_of; +fn stale_bound_color_document() -> (figcraft_doc::Document, figcraft_doc::NodeId) { + let mut s = Session::new(); + let id = id_of(&s.execute("shape.rectangle", &json!({"x": 4000, "y": 4000, "width": 23, "height": 17})).unwrap()["id"]).unwrap(); + let page = s.doc().unwrap().first_page().unwrap(); + s.execute("variable.createCollection", &json!({"name": "Theme", "modes": ["Light", "Dark"]})).unwrap(); + s.execute("variable.create", &json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#FFFFFF"})).unwrap(); + s.execute("variable.create", &json!({"collection": "Theme", "name": "size", "type": "number", "value": 23})).unwrap(); + s.execute("variable.set", &json!({"variable": "surface", "mode": "Dark", "value": "#202124"})).unwrap(); + s.execute("variable.set", &json!({"variable": "size", "mode": "Dark", "value": 99})).unwrap(); + s.execute("variable.bind", &json!({"ids": [id], "field": "fill", "variable": "surface"})).unwrap(); + s.execute("variable.bind", &json!({"ids": [id], "field": "width", "variable": "size"})).unwrap(); + s.execute("variable.setMode", &json!({"collection": "Theme", "mode": "Dark"})).unwrap(); + let mut doc = s.doc().unwrap().clone(); + let node = doc.get_mut(id).unwrap(); + node.name = "load-binding-sentinel".into(); + node.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)]; + node.width = 5.0; + node.source = Some(page); + node.overrides.insert("sentinel".into()); + doc.take_dirty(); + (doc, id) +} + +fn assert_dark_bound_color(doc: &figcraft_doc::Document, transform: figcraft_doc::geom::Affine) -> figcraft_doc::NodeId { + let node = doc.nodes().find(|n| n.name == "load-binding-sentinel").unwrap(); + assert_eq!(node.fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()), Some("202124".into())); + assert_eq!((node.transform, node.width, node.height), (transform, 5.0, 17.0)); + assert!(node.bound.contains_key("fills/0/color") || node.bound.contains_key("fill")); + node.id +} + +#[test] +fn initial_color_bindings_settle_in_constructor_and_loaders() { + let (stale, _id) = stale_bound_color_document(); + let name_transform = stale.nodes().find(|n| n.name == "load-binding-sentinel").unwrap().transform; + + let mut state = Session::with_document(stale.clone()); + let id = assert_dark_bound_color(state.doc().unwrap(), name_transform); + assert_eq!(state.doc().unwrap().node(id).unwrap().source, stale.first_page()); + assert!(state.doc().unwrap().node(id).unwrap().overrides.contains("sentinel")); + assert!(!state.ds().unwrap().is_dirty()); + assert!(state.doc().unwrap().dirty().is_empty()); + assert_eq!(state.undo_depth(), (0, 0)); + state.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 2, "height": 2})).unwrap(); + assert_dark_bound_color(state.doc().unwrap(), name_transform); + + let native = figcraft_format::save_bytes(&stale, None).unwrap(); + let (loaded, _) = crate::cmd::file::load_bytes(&native, "bindings.figcraft").unwrap(); + let loaded_id = assert_dark_bound_color(&loaded, name_transform); + assert_eq!(loaded_id, id); + assert_eq!(loaded.node(id).unwrap().source, stale.first_page()); + assert!(loaded.node(id).unwrap().overrides.contains("sentinel")); + assert!(loaded.dirty().is_empty()); + + let (fig, _) = figcraft_format::fig::export(&stale, None).unwrap(); + let (loaded, _) = crate::cmd::file::load_bytes(&fig, "bindings.fig").unwrap(); + assert_dark_bound_color(&loaded, name_transform); +} + +#[test] +fn initial_paint_settlement_preserves_existing_dirty_nodes() { + let (mut doc, _) = stale_bound_color_document(); + let page = doc.first_page().unwrap(); + doc.mark_dirty([page]); + + crate::cmd::styles::apply_paint_bindings_on_load(&mut doc); + + assert_eq!(doc.dirty().iter().copied().collect::>(), [page]); +} + #[test] fn bound_layers_follow_variable_values_and_unbind() { let mut s = Session::new(); diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 9f9bacab..eb982827 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -77,11 +77,33 @@ fn guid(v: &Kv) -> Option { Some(format!("{}:{}", num(v, "sessionID")? as u64, num(v, "localID")? as u64)) } +/// An override row names a layer of the main by its current GUID or by the `overrideKey` the +/// layer had when the row was written: the key becomes the current GUID, unless the layer is +/// current already or the key is ambiguous. +fn normalize_override_segment(path: &mut [String], index: usize, scope: &OverrideScope) { + let Some(segment) = path.get(index) else { return }; + if scope.current.contains(segment) { + return; + } + if let Some(Some(current)) = scope.aliases.get(segment) { + path[index] = current.clone(); + } +} + /// A `GUID`-holding reference (`StyleId`, `VariableID`, …): its `guid`. fn ref_guid(v: &Kv) -> Option { v.get("guid").and_then(guid).filter(|g| g != NO_STYLE) } +/// The style a layer links in `key`, or in the older `legacy` field when `key` names none. An +/// explicit NO_STYLE in `key` is a detach and wins over the legacy field. +fn style_link(v: &View, key: &str, legacy: &str) -> Option { + match v.get(key).and_then(|r| r.get("guid")).and_then(guid) { + Some(id) => Some(id).filter(|g| g != NO_STYLE), + None => v.get(legacy).and_then(guid).filter(|g| g != NO_STYLE), + } +} + fn color(c: &Kv) -> Value { let ch = |k: &str| num(c, k).unwrap_or(0.0); json!({"r": ch("r"), "g": ch("g"), "b": ch("b"), "a": num(c, "a").unwrap_or(1.0)}) @@ -224,7 +246,7 @@ impl Fit { } /// Overrides and derived layout by id path. -type Overrides<'a> = BTreeMap, Vec<(Entry, &'a Kv<'a>)>>; +type Overrides<'a> = BTreeMap, Vec>>; /// Where an override entry comes from. #[derive(Clone, Copy, PartialEq)] @@ -236,6 +258,25 @@ enum Entry { Stale, } +/// One override or derived-layout row addressed to a layer. +#[derive(Clone)] +struct OverrideRow<'a> { + row: &'a Kv<'a>, + entry: Entry, + /// How many mains are being expanded around the instance that wrote the row: the rows of + /// the enclosing (outer) instances win over those of the inner ones. + source_depth: usize, + /// Position among the rows of the instance that wrote it; later rows win. + ordinal: usize, +} + +/// The layers of a main by current GUID, and the old `overrideKey` of each that has one +/// (`None` when two layers claim the same key). +struct OverrideScope { + current: BTreeSet, + aliases: BTreeMap>, +} + /// Where an instance's expanded layers are: the overrides and derived layout of the outermost /// instance (by id path), the path of the instance being expanded, and the property values. #[derive(Clone)] @@ -262,6 +303,10 @@ struct Mapper<'a> { /// Descriptions of components and component sets (REST `components` / `componentSets`). components: Obj, sets: Obj, + /// Layers and keys of each main, built when an instance of it first needs them. + override_scopes: BTreeMap>, + /// Entries held by `override_scopes` (bounded by [`MAX_NODES`] together). + override_scope_entries: usize, /// Component-bearing branches of the file's internal canvases (cached library definitions). library_contexts: Vec, /// Layers visited while scanning internal canvases, across all of them. @@ -329,6 +374,8 @@ impl<'a> Mapper<'a> { styles: Obj::new(), components: Obj::new(), sets: Obj::new(), + override_scopes: BTreeMap::new(), + override_scope_entries: 0, library_contexts: Vec::new(), library_scan_count: 0, max_nodes: MAX_NODES, @@ -369,6 +416,50 @@ impl<'a> Mapper<'a> { self.kids.get(id).map(|k| k.iter().map(|(_, c)| c.clone()).collect()).unwrap_or_default() } + /// The layers of main `main` by current GUID and by `overrideKey`. An instance nested in the + /// main is not entered: its layers belong to another main. + fn override_scope(&mut self, main: &str) -> Rc { + if let Some(scope) = self.override_scopes.get(main) { + return Rc::clone(scope); + } + let mut current = BTreeSet::new(); + let mut aliases: BTreeMap> = BTreeMap::new(); + let mut visited = BTreeSet::new(); + let mut pending: Vec<(String, usize)> = self.children(main).into_iter().map(|id| (id, 1)).collect(); + let mut complete = true; + while let Some((id, depth)) = pending.pop() { + if depth > MAX_DEPTH || visited.len() >= MAX_NODES { + complete = false; + break; + } + if !visited.insert(id.clone()) { + continue; + } + let Some(node) = self.nodes.get(&id) else { continue }; + current.insert(id.clone()); + if let Some(alias) = node.get("overrideKey").and_then(guid) { + let target = aliases.entry(alias).or_insert_with(|| Some(id.clone())); + if target.as_deref().is_some_and(|target| target != id) { + *target = None; + } + } + if node.get("type").and_then(Kv::str) != Some("INSTANCE") { + pending.extend(self.children(&id).into_iter().map(|child| (child, depth + 1))); + } + } + // A scope cut short could name the wrong layer for a key: use none of the keys. + if !complete { + aliases.clear(); + } + let scope = Rc::new(OverrideScope { current, aliases }); + let cost = scope.current.len() + scope.aliases.len(); + if cost <= MAX_NODES.saturating_sub(self.override_scope_entries) { + self.override_scope_entries += cost; + self.override_scopes.insert(main.to_string(), Rc::clone(&scope)); + } + scope + } + /// The DOCUMENT with its pages. fn document(&mut self) -> Value { let doc_id = self.nodes.iter().find(|(_, n)| n.get("type").and_then(Kv::str) == Some("DOCUMENT")).map(|(id, _)| id.clone()); @@ -622,24 +713,24 @@ impl<'a> Mapper<'a> { } let base = *self.nodes.get(id)?; let path = inst.map(|i| [i.path.as_slice(), &[id.to_string()]].concat()); - let entries = match (inst, &path) { + let entries: &[OverrideRow<'a>] = match (inst, &path) { (Some(i), Some(p)) => i.overrides.get(p).map(Vec::as_slice).unwrap_or_default(), _ => &[], }; - let mut view = View::of(base).with(entries.iter().map(|e| e.1)); + let mut view = View::of(base).with(entries.iter().map(|e| e.row)); // A layer of a resized instance the file gives no derived layout for follows its // constraints from the main's geometry, then takes the size and place overridden on it // (auto layout places the others, as in the editor). - let derived = entries.iter().any(|(d, l)| *d == Entry::Derived && (l.get("size").is_some() || l.get("transform").is_some())); + let derived = entries.iter().any(|e| e.entry == Entry::Derived && (e.row.get("size").is_some() || e.row.get("transform").is_some())); let stacked = parent.and_then(|p| p.str("stackMode")).is_some_and(|m| matches!(m, "HORIZONTAL" | "VERTICAL" | "GRID")) && view.str("stackPositioning") != Some("ABSOLUTE"); let main = View::of(base); - let set = |k: &str| entries.iter().rev().filter(|e| e.0 != Entry::Stale).find_map(|(_, l)| l.get(k)); + let set = |k: &str| entries.iter().rev().filter(|e| e.entry != Entry::Stale).find_map(|e| e.row.get(k)); if let (Some(f), false, false, Some((w, h))) = (fit, derived, stacked, main.size()) { let t = main.transform().unwrap_or_default(); let (fw, ft) = f.apply(w, h, t, view.str("horizontalConstraint"), view.str("verticalConstraint")); view.fitted = Some((set("size").and_then(size).unwrap_or(fw), set("transform").and_then(matrix).unwrap_or(ft))); - } else if entries.iter().any(|(d, l)| *d == Entry::Stale && (l.get("size").is_some() || l.get("transform").is_some())) + } else if entries.iter().any(|e| e.entry == Entry::Stale && (e.row.get("size").is_some() || e.row.get("transform").is_some())) && let Some(s) = set("size").and_then(size).or_else(|| main.size()) { view.fitted = Some((s, set("transform").and_then(matrix).or_else(|| main.transform()).unwrap_or_default())); @@ -932,7 +1023,7 @@ impl<'a> Mapper<'a> { /// The style node a layer links in `key` (or the older `legacy` field), recorded for the REST /// `styles` map under `slot`. fn style(&mut self, v: &View<'a>, key: &str, legacy: &str, slot: &str, out: &mut Obj) -> Option<&'a Kv<'a>> { - let id = v.get(key).and_then(ref_guid).or_else(|| v.get(legacy).and_then(guid).filter(|g| g != NO_STYLE))?; + let id = style_link(v, key, legacy)?; let node = *self.nodes.get(&id)?; node.get("styleType").and_then(Kv::str)?; out.insert(slot.into(), json!(id)); @@ -1093,10 +1184,13 @@ impl<'a> Mapper<'a> { fn network(&mut self, o: &mut Obj, v: &View<'a>) -> bool { let Some(vd) = v.get("vectorData") else { return false }; let Some(blob) = num(vd, "vectorNetworkBlob").and_then(|i| self.blobs.get(i as usize)).copied() else { return false }; - let Some(net) = network::decode(blob) else { + let Some((net, styled_segments)) = network::decode_styled(blob) else { self.warn("damaged vector networks import as outlines"); return false; }; + if styled_segments > 0 { + self.warn("stroke styles of single vector network segments were left out"); + } // An open path shows its fill, which regions can't hold: the outline keeps the look. let fills_shown = o.get("fills").and_then(Value::as_array).is_some_and(|f| f.iter().any(|p| p.get("visible").and_then(Value::as_bool) != Some(false))); @@ -1277,7 +1371,18 @@ impl<'a> Mapper<'a> { } // Overrides by id path below this instance (the instance itself is addressed by its main's id). let own_path: Vec = outer.map(|i| [i.path.as_slice(), &[id.to_string()]].concat()).unwrap_or_default(); - let mut overrides: Overrides<'a> = outer.map(|i| (*i.overrides).clone()).unwrap_or_default(); + let scope = self.override_scope(&sym); + let mut overrides: Overrides<'a> = BTreeMap::new(); + if let Some(inherited) = outer { + for (path, rows) in inherited.overrides.iter() { + let mut path = path.clone(); + // Rows an outer instance wrote for this instance's layers may use their old keys. + if path.starts_with(&own_path) && path.len() > own_path.len() { + normalize_override_segment(&mut path, own_path.len(), &scope); + } + overrides.entry(path).or_default().extend(rows.iter().cloned()); + } + } // A nested instance's derived layout is for the size it has in its main: resized here, the // sizes and places in it no longer hold and its layers follow their constraints instead. let differs = |a: f64, b: f64| (a - b).abs() > 1e-6 * a.abs().max(b.abs()).max(1.0); @@ -1286,30 +1391,41 @@ impl<'a> Mapper<'a> { (Some(a), Some(b)) => differs(a.0, b.0) || differs(a.1, b.1), (a, b) => a.is_some() != b.is_some(), }; + let mut local: Overrides<'a> = BTreeMap::new(); let lists = [sd.and_then(|s| s.get("symbolOverrides")).map(Kv::list).unwrap_or_default(), v.list("derivedSymbolData")]; + let source_depth = outer.map_or(0, |i| i.symbols.len()); + let mut ordinal = 0; for (layer, list) in lists.into_iter().enumerate() { for ov in list { - let rel: Vec = + let row_ordinal = ordinal; + ordinal += 1; + let mut rel: Vec = ov.get("guidPath").and_then(|g| g.get("guids")).map(Kv::list).unwrap_or_default().iter().filter_map(guid).collect(); if rel.is_empty() || rel == [sym.clone()] { continue; } - let e = overrides.entry([own_path.as_slice(), &rel].concat()).or_default(); - // The outer instance's overrides win over this one's own; derived layout comes last. - if layer == 0 && outer.is_some() { - e.insert(0, (Entry::Override, ov)); - } else { - e.push(( - match (layer, resized) { - (0, _) => Entry::Override, - (_, false) => Entry::Derived, - (_, true) => Entry::Stale, - }, - ov, - )); - } + normalize_override_segment(&mut rel, 0, &scope); + // Explicit overrides, then derived layout (stale once the instance is resized). + let entry = match (layer, resized) { + (0, _) => Entry::Override, + (_, false) => Entry::Derived, + (_, true) => Entry::Stale, + }; + local.entry([own_path.as_slice(), &rel].concat()).or_default().push(OverrideRow { + row: ov, + entry, + source_depth, + ordinal: row_ordinal, + }); } } + for (path, rows) in local { + overrides.entry(path).or_default().extend(rows); + } + for rows in overrides.values_mut() { + // Deeper source rows apply first; outer rows win, with wire order preserved per source. + rows.sort_by_key(|row| (std::cmp::Reverse(row.source_depth), row.ordinal)); + } let mut all_props: BTreeMap> = outer.map(|i| (*i.props).clone()).unwrap_or_default(); for a in assignments { if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), a.get("value")) { diff --git a/crates/format/src/fig/network.rs b/crates/format/src/fig/network.rs index 112a767a..d251dff1 100644 --- a/crates/format/src/fig/network.rs +++ b/crates/format/src/fig/network.rs @@ -65,7 +65,14 @@ impl Reader<'_> { } /// The network in blob `b`; `None` when it's truncated, out of range or not finite. +#[allow(dead_code, reason = "for readers that don't need the count of styled segments")] pub(super) fn decode(b: &[u8]) -> Option { + decode_styled(b).map(|(net, _)| net) +} + +/// The network in blob `b` and how many of its segments have a style id of their own (per-segment +/// stroke overrides, which the document model can't hold); `None` as for `decode`. +pub(super) fn decode_styled(b: &[u8]) -> Option<(Network, usize)> { let mut r = Reader { b, pos: 0 }; let (nv, ns, nr) = (r.u32()? as usize, r.u32()? as usize, r.u32()? as usize); if nv > MAX_ITEMS || ns > MAX_ITEMS || nr > MAX_ITEMS || nv * 12 + ns * 28 + nr * 8 > b.len().saturating_sub(12) { @@ -75,8 +82,11 @@ pub(super) fn decode(b: &[u8]) -> Option { for _ in 0..nv { net.vertices.push(Vertex { style: r.u32()?, x: r.f32()?, y: r.f32()? }); } + let mut styled = 0usize; for _ in 0..ns { - r.u32()?; + if r.u32()? != 0 { + styled += 1; + } net.segments.push(Segment { a: r.index(nv)?, ta: (r.f32()?, r.f32()?), b: r.index(nv)?, tb: (r.f32()?, r.f32()?) }); } let mut entries = 0usize; @@ -93,7 +103,7 @@ pub(super) fn decode(b: &[u8]) -> Option { } net.regions.push(Region { style: sw >> 1, non_zero: sw & 1 == 1, loops }); } - Some(net) + Some((net, styled)) } /// The blob of `net` (the export writes it; so do test fixtures). Callers pass finite values. diff --git a/crates/format/src/fig/source_identity_tests.rs b/crates/format/src/fig/source_identity_tests.rs index 955df1da..6667e4a2 100644 --- a/crates/format/src/fig/source_identity_tests.rs +++ b/crates/format/src/fig/source_identity_tests.rs @@ -455,6 +455,166 @@ fn fill_rgb_node(doc: &Document, id: NodeId) -> Option<[f64; 3]> { } } +fn alias_schema() -> Schema { + let schema = export::SCHEMA.replace("message NodeChange GUID guid=1,", "message NodeChange GUID guid=1, GUID overrideKey=500,"); + Schema::parse(&schema).unwrap() +} + +fn alias(id: u64) -> Fields { + vec![("overrideKey", g(id))] +} + +fn import_alias_message(message: Kv<'static>) -> Document { + let schema = alias_schema(); + let bytes = super::tests::canvas(&schema, &message); + let (doc, warnings) = import(&bytes, "Alias.fig").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + doc +} + +fn fill_rgb(doc: &Document, name: &str) -> Option<[f64; 3]> { + let node = doc.nodes().find(|n| n.name == name)?; + fill_rgb_node(doc, node.id) +} + +fn alias_priority_message() -> Kv<'static> { + let nested_local = symbol(10, vec![row(&[112], vec![("fillPaints", Kv::Array(vec![solid(0.0, 0.0, 1.0)]))])]); + o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "DOCUMENT", "Document", vec![]), + node(2, Some(1), "CANVAS", "Page", vec![]), + node(10, Some(2), "SYMBOL", "Inner main", vec![]), + node(11, Some(10), "FRAME", "Normal frame", vec![]), + node(12, Some(11), "ROUNDED_RECTANGLE", "Alias leaf", alias(112)), + node(20, Some(2), "SYMBOL", "Outer main", vec![]), + node( + 21, + Some(20), + "INSTANCE", + "Nested slot", + vec![ + ("overrideKey", g(221)), + ("symbolData", nested_local), + ("derivedSymbolData", Kv::Array(vec![row(&[112], vec![("opacity", Kv::Float(0.25))])])), + ], + ), + node( + 30, + Some(2), + "INSTANCE", + "Alias copy", + vec![ + ( + "symbolData", + symbol( + 20, + vec![ + row(&[221, 112], vec![("fillPaints", Kv::Array(vec![solid(0.0, 1.0, 0.0)]))]), + row(&[21, 12], vec![("fillPaints", Kv::Array(vec![solid(1.0, 0.0, 0.0)]))]), + ], + ), + ), + ("derivedSymbolData", Kv::Array(vec![row(&[221, 112], vec![("fillPaints", Kv::Array(vec![solid(1.0, 0.5, 0.0)]))])])), + ], + ), + ]), + )]) +} + +#[test] +fn override_aliases_normalize_lazily_and_keep_outer_wire_order() { + let doc = import_alias_message(alias_priority_message()); + let main = named(&doc, "Inner main"); + let leaf = child(&doc, child(&doc, main, 0), 0); + let outer_main = named(&doc, "Outer main"); + let main_slot = child(&doc, outer_main, 0); + let main_leaf = child(&doc, child(&doc, main_slot, 0), 0); + let copy = named(&doc, "Alias copy"); + let nested = child(&doc, copy, 0); + let frame = child(&doc, nested, 0); + let expanded_leaf = child(&doc, frame, 0); + assert_eq!(doc.node(main_leaf).unwrap().source, Some(leaf)); + assert_eq!(doc.node(expanded_leaf).unwrap().source, Some(main_leaf)); + assert_eq!(fill_rgb_node(&doc, expanded_leaf), Some([1.0, 0.5, 0.0])); + assert_eq!(doc.node(expanded_leaf).unwrap().opacity, 0.25); + assert_eq!(fill_rgb_node(&doc, leaf), None, "source node stays unchanged"); +} + +#[test] +fn selected_swapped_main_owns_alias_resolution_scope() { + let message = o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "DOCUMENT", "Document", vec![]), + node(2, Some(1), "CANVAS", "Page", vec![]), + node(10, Some(2), "SYMBOL", "Old main", vec![]), + node(11, Some(10), "ROUNDED_RECTANGLE", "Old leaf", alias(112)), + node(20, Some(2), "SYMBOL", "Swapped main", vec![]), + node(21, Some(20), "ROUNDED_RECTANGLE", "Swapped leaf", alias(112)), + node( + 30, + Some(2), + "INSTANCE", + "Swap", + vec![ + ("overriddenSymbolID", g(20)), + ("symbolData", symbol(10, vec![row(&[112], vec![("fillPaints", Kv::Array(vec![solid(1.0, 0.0, 0.0)]))])])), + ], + ), + ]), + )]); + let doc = import_alias_message(message); + let copy = named(&doc, "Swap"); + let source = named(&doc, "Swapped leaf"); + let expanded = child(&doc, copy, 0); + assert_eq!(doc.node(expanded).unwrap().source, Some(source)); + assert_eq!(fill_rgb_node(&doc, expanded), Some([1.0, 0.0, 0.0])); + assert_eq!(fill_rgb(&doc, "Old leaf"), None); +} + +#[test] +fn ambiguous_alias_is_ignored_but_current_guid_still_matches() { + let message = o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "DOCUMENT", "Document", vec![]), + node(2, Some(1), "CANVAS", "Page", vec![]), + node(10, Some(2), "SYMBOL", "Main", vec![]), + node(11, Some(10), "ROUNDED_RECTANGLE", "First", alias(13)), + node(12, Some(10), "ROUNDED_RECTANGLE", "Second", alias(112)), + node(13, Some(10), "ROUNDED_RECTANGLE", "Current GUID", alias(112)), + node( + 20, + Some(2), + "INSTANCE", + "Copy", + vec![( + "symbolData", + symbol( + 10, + vec![ + row(&[112], vec![("fillPaints", Kv::Array(vec![solid(1.0, 0.0, 0.0)]))]), + row(&[13], vec![("fillPaints", Kv::Array(vec![solid(0.0, 0.0, 1.0)]))]), + ], + ), + )], + ), + ]), + )]); + let doc = import_alias_message(message); + let copy = named(&doc, "Copy"); + let first = child(&doc, copy, 0); + let second = child(&doc, copy, 1); + let current = child(&doc, copy, 2); + assert_eq!(doc.node(first).unwrap().source, Some(named(&doc, "First"))); + assert_eq!(fill_rgb_node(&doc, first), None, "ambiguous alias must not be guessed"); + assert_eq!(doc.node(second).unwrap().source, Some(named(&doc, "Second"))); + assert_eq!(fill_rgb_node(&doc, second), None, "ambiguous alias must not match either target"); + assert_eq!(doc.node(current).unwrap().source, Some(named(&doc, "Current GUID"))); + assert_eq!(fill_rgb_node(&doc, current), Some([0.0, 0.0, 1.0]), "a current GUID outranks an alias with the same key"); +} + fn message(swapped: bool) -> Kv<'static> { let shape = |id, parent, x| node(id, Some(parent), "ROUNDED_RECTANGLE", "Same layer", vec![("transform", matrix(x))]); let mut changes = vec![ @@ -936,3 +1096,25 @@ fn external_namespace_rejects_foreign_siblings_duplicates_and_bad_paths() { assert!(warnings.iter().any(|w| w.contains("source link")), "invalid expanded paths remain reported: {warnings:?}"); } } + +fn no_style() -> Kv<'static> { + o(vec![("sessionID", Kv::Uint(4_294_967_295)), ("localID", Kv::Uint(4_294_967_295))]) +} + +#[test] +fn an_explicit_no_style_detaches_and_beats_the_older_style_field() { + let message = o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "DOCUMENT", "Document", vec![]), + node(2, Some(1), "CANVAS", "Page", vec![]), + node(5, Some(2), "RECTANGLE", "Red", vec![("styleType", Kv::Enum("FILL"))]), + node(6, Some(2), "RECTANGLE", "Box", vec![("styleIdForFill", o(vec![("guid", no_style())])), ("inheritFillStyleID", g(5))]), + node(7, Some(2), "RECTANGLE", "Linked", vec![("inheritFillStyleID", g(5))]), + ]), + )]); + let (json, _) = super::map::to_rest(&message, "Detach"); + let find = |name: &str| json["document"]["children"][0]["children"].as_array().unwrap().iter().find(|n| n["name"] == name).unwrap().clone(); + assert!(find("Box")["styles"].get("fill").is_none(), "{}", find("Box")["styles"]); + assert!(find("Linked")["styles"].get("fill").is_some(), "the older field still links when nothing detaches"); +} diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index fc0c5815..5d34f23b 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -262,6 +262,25 @@ fn vectors_import_as_editable_networks() { assert!(warnings.iter().any(|w| w.contains("damaged vector networks import as outlines")), "{warnings:?}"); } +#[test] +fn per_segment_stroke_styles_are_reported() { + let mut blob = square_blob(); + // The first segment's style id follows the counts and the vertices (12 bytes each). + blob[12 + 4 * 12..12 + 4 * 12 + 4].copy_from_slice(&7u32.to_le_bytes()); + let (net, styled) = network::decode_styled(&blob).unwrap(); + assert_eq!((net.segments.len(), styled), (4, 1)); + assert_eq!(network::decode_styled(&square_blob()).unwrap().1, 0); + let plain = import(&tail_canvas(), "Tail").unwrap().1; + assert!(!plain.iter().any(|w| w.contains("stroke styles of single")), "{plain:?}"); + let mut message = tail_message(); + let Kv::Object(fields) = &mut message else { panic!() }; + let Kv::Array(blobs) = &mut fields.iter_mut().find(|(key, _)| *key == "blobs").unwrap().1 else { panic!() }; + blobs[1] = o(vec![("bytes", Kv::Bytes(blob))]); + let (doc, warnings) = import(&canvas(&schema(), &message), "Tail").unwrap(); + assert!(warnings.iter().any(|w| w.contains("stroke styles of single vector network segments were left out")), "{warnings:?}"); + assert_eq!(network_of(&doc, "Wave").segments.len(), 4); +} + #[test] fn other_layer_kinds_import_as_the_closest_layers() { let (doc, warnings) = import(&tail_canvas(), "Tail").unwrap(); diff --git a/crates/format/src/rest/import_regression_tests.rs b/crates/format/src/rest/import_regression_tests.rs new file mode 100644 index 00000000..2d6364be --- /dev/null +++ b/crates/format/src/rest/import_regression_tests.rs @@ -0,0 +1,59 @@ +//! Stroke-only vectors that import as the outline of their stroke keep the stroke's style and variables. + +use super::proto_variable_tests::{BRAND, alias, variables_json}; +use super::*; +use serde_json::json; + +fn at(x: f64, y: f64) -> Value { + json!([[1, 0, x], [0, 1, y]]) +} + +fn file(children: Value) -> Value { + json!({"name": "Fixture", "schemaVersion": 0, "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT", "children": [ + {"id": "0:1", "name": "Page", "type": "CANVAS", "children": children} + ]}}) +} + +fn named<'a>(doc: &'a Document, name: &str) -> &'a Node { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no node {name}")) +} + +/// A stroke-only icon vector as `geometry=paths` returns it: no fills, one visible stroke. +fn stroke_only_vector(id: &str, name: &str, stroke: Value) -> Value { + json!({"id": id, "name": name, "type": "VECTOR", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}, + "fills": [], "strokes": [stroke], "strokeWeight": 2, + "fillGeometry": [], + "strokeGeometry": [{"path": "M 0 0 L 10 0 L 10 2 L 0 2 Z", "windingRule": "NONZERO"}], + "styles": {"stroke": "S:stroke"}}) +} + +#[test] +fn stroke_only_vector_shared_stroke_style_keeps_paints() { + let stroke = json!({"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0, "a": 1}}); + let mut v = file(json!([stroke_only_vector("1:1", "Icon", stroke)])); + v["styles"] = json!({"S:stroke": {"key": "k", "name": "Brand/Stroke", "styleType": "FILL"}}); + let (doc, _) = import(&v, &no_images).unwrap(); + let icon = named(&doc, "Icon"); + assert!(matches!(icon.kind, NodeKind::Vector { .. })); + let style = doc.style("S:stroke").expect("shared stroke style is created"); + match &style.kind { + StyleKind::Paint { paints } => assert_eq!(paints.len(), 1, "shared stroke style must keep the stroke paint, got {paints:?}"), + k => panic!("unexpected style kind {k:?}"), + } +} + +#[test] +fn stroke_colour_variable_on_outlined_vector_is_kept() { + let stroke = json!({"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0, "a": 1}, "boundVariables": {"color": alias(BRAND)}}); + let mut node = stroke_only_vector("1:1", "Icon", stroke); + node.as_object_mut().unwrap().remove("styles"); + node["boundVariables"] = json!({"strokes": [alias(BRAND)]}); + let (doc, warnings) = import_with(&file(json!([node])), Some(&variables_json()), &no_images).unwrap(); + let icon = named(&doc, "Icon"); + // The stroke paint now paints the outline as its fill: its colour token must follow it. + assert!( + icon.bound.values().any(|v| v == BRAND), + "colour variable bound on the outlined stroke was dropped; bound={:?} warnings={warnings:?}", + icon.bound + ); +} diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 8c41ffb3..282c6a75 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -28,6 +28,8 @@ mod effects_tests; mod fig_sources; #[cfg(test)] mod gamepad_tests; +#[cfg(test)] +mod import_regression_tests; pub(crate) mod props; pub(crate) mod proto; #[cfg(test)] @@ -232,7 +234,7 @@ fn style_binding(slot: &str, key: &str) -> Option { /// Put back the shape a layer was before the `.fig` export wrote it as the outline of its stroke /// (`outlinedStroke`: `{kind, fills, strokes, stroke}` as the document holds them). Anything that /// isn't such a shape is ignored. -fn restore_stroke(n: &mut Node, original: &Value) { +fn restore_stroke(n: &mut Node, original: &Value) -> bool { #[derive(serde::Deserialize)] struct Original { kind: NodeKind, @@ -240,16 +242,16 @@ fn restore_stroke(n: &mut Node, original: &Value) { strokes: Vec, stroke: figcraft_doc::StrokeProps, } - let Ok(mut o) = serde_json::from_value::(original.clone()) else { return }; + let Ok(mut o) = serde_json::from_value::(original.clone()) else { return false }; match &mut o.kind { NodeKind::Vector { network } => { if network.vertices.len() + network.segments.len() > MAX_PATH_ELEMENTS { - return; + return false; } network.sanitize(); } NodeKind::Line | NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Rectangle => {} - _ => return, + _ => return false, } let sp = &mut o.stroke; sp.weight = sp.weight.clamp(0.0, props::MAX_SIZE); @@ -259,6 +261,28 @@ fn restore_stroke(n: &mut Node, original: &Value) { o.fills.truncate(64); o.strokes.truncate(64); (n.kind, n.fills, n.strokes, n.stroke) = (o.kind, o.fills, o.strokes, o.stroke); + true +} + +/// A stroke-only vector imported as the outline of its stroke: the stroke paints fill the +/// outline, and the stroke's shared style and colour variables move with them. The hidden fills +/// it replaces go, with their style and bindings. +fn outline_stroke(n: &mut Node) { + n.fills = std::mem::take(&mut n.strokes); + n.styles.fill = n.styles.stroke.take(); + let bound = std::mem::take(&mut n.bound); + for (key, var) in bound { + let moved = match key.as_str() { + "fill" => continue, + k if k.starts_with("fills/") => continue, + "stroke" => "fill".to_string(), + k => match k.strip_prefix("strokes/") { + Some(rest) => format!("fills/{rest}"), + None => key, + }, + }; + n.bound.insert(moved, var); + } } fn children(v: &Value) -> &[Value] { @@ -585,9 +609,7 @@ impl<'a> Ctx<'a> { n.prop_refs.insert(field.clone(), p.to_string()); } } - if matches!(n.kind, NodeKind::Vector { .. }) { - self.vector(n, j); - } + let outlined = matches!(n.kind, NodeKind::Vector { .. }) && self.vector(n, j); if matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }) { let defs = self.prop_defs(n.id, j); match &mut n.kind { @@ -608,20 +630,24 @@ impl<'a> Ctx<'a> { self.overlay_meta.entry(rid).or_insert(j); } self.bindings(n, j); - if let Some(original) = j.get("outlinedStroke") { - restore_stroke(n, original); + let restored = j.get("outlinedStroke").is_some_and(|original| restore_stroke(n, original)); + // Styles and bindings were read against the stroke: they follow its paints to the fill. + if outlined && !restored { + outline_stroke(n); } } /// A vector's network: the editable one when the JSON carries it (`vectorNetwork`, our /// extension, written by the `.fig` import), else from its geometry. A stroke-only vector - /// uses the stroke outline, painted with the stroke paints (exact look, editable as a shape). - fn vector(&mut self, n: &mut Node, j: &Value) { + /// uses the stroke outline, painted with the stroke paints (exact look, editable as a shape): + /// returns whether it does, and the caller moves the paints with [`outline_stroke`] once the + /// styles and variables of the stroke are read. + fn vector(&mut self, n: &mut Node, j: &Value) -> bool { if let Some(net) = j.get("vectorNetwork") { match network(net) { Some(network) => { n.kind = NodeKind::Vector { network }; - return; + return false; } None => self.warn("invalid or oversized vector networks import as outlines"), } @@ -636,17 +662,15 @@ impl<'a> Ctx<'a> { (None, Some(s)) => (s, true), (None, None) => { self.warn("vectors without geometry are empty (export the file JSON with geometry=paths)"); - return; + return false; } }; let mut network = VectorNetwork::from_path(&path); for r in &mut network.regions { r.winding = winding; } - if outline { - n.fills = std::mem::take(&mut n.strokes); - } n.kind = NodeKind::Vector { network }; + outline } /// The paths of a `fillGeometry` / `strokeGeometry` list (node-local SVG path data). diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d8e51711..e56eda19 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -17,7 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, per-segment stroke styles, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout, constraints, grids, components, sets, instances with overrides, component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | From d2606b3f51142df017efbcc36d922e0b68a553b7 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 01:45:25 +0200 Subject: [PATCH 010/118] M1: let local text values and instance overrides win over a shared text style in .fig import A text layer that links a shared text style keeps its own, possibly stale, copy of the typography. The style's typography now beats that copy, a case, decoration, paragraph spacing or paragraph indent set on the layer itself beats the style (so "original", "none" and zero clear the style's value), and an override row of an instance beats both. An explicit "no style" on a text layer unlinks it even when the older style field still names one. Letter spacing given as a percentage of the font size stays a percentage. A text property value on an instance no longer inherits the character styles written for the main's text, which count other characters. --- crates/format/src/fig/map.rs | 84 +++++-- crates/format/src/fig/mod.rs | 2 + crates/format/src/fig/text_style_tests.rs | 292 ++++++++++++++++++++++ crates/format/src/rest/text.rs | 6 + 4 files changed, 363 insertions(+), 21 deletions(-) create mode 100644 crates/format/src/fig/text_style_tests.rs diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index eb982827..0fe44662 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -167,6 +167,16 @@ impl<'a> View<'a> { v } + /// This view with `style` just above the node itself: the style beats the node's own copy of + /// its fields (stale when the style changed since), and override rows beat both. + fn styled(&self, style: Option<&'a Kv<'a>>) -> Self { + let mut v = self.clone(); + if let Some(s) = style { + v.layers.insert(v.layers.len().min(1), s); + } + v + } + fn get(&self, k: &str) -> Option<&'a Kv<'a>> { self.layers.iter().rev().find_map(|l| l.get(k)) } @@ -1095,19 +1105,30 @@ impl<'a> Mapper<'a> { } fn text(&mut self, o: &mut Obj, v: &View<'a>) { - let style_node = v - .get("styleIdForText") - .and_then(ref_guid) - .or_else(|| v.get("inheritTextStyleID").and_then(guid)) - .and_then(|s| self.nodes.get(&s).copied()); - let sv = v.with(style_node.filter(|s| s.get("styleType").and_then(Kv::str) == Some("TEXT"))); + let style_node = style_link(v, "styleIdForText", "inheritTextStyleID").and_then(|s| self.nodes.get(&s).copied()); + // Instance overrides beat the shared style; the style beats the layer's own copy. + let sv = v.styled(style_node.filter(|s| s.get("styleType").and_then(Kv::str) == Some("TEXT"))); let td = v.get("textData"); let chars = td.and_then(|t| t.get("characters")).and_then(Kv::str).unwrap_or(""); o.insert("characters".into(), json!(chars)); let mut style = text_style(&sv, None); + // The style's typography beats the layer's own copy of it (the copy is stale when the style + // changed). The file cannot tell a stale copy of these four values from a deliberate local + // one, and "original", "none" and 0 could never be set locally if the style always won, so + // a value the layer or an override sets itself is taken as deliberate. + if let Some(decoration @ ("NONE" | "UNDERLINE" | "STRIKETHROUGH")) = v.str("textDecoration") { + style.insert("textDecoration".into(), json!(decoration)); + } + if let Some(case @ ("ORIGINAL" | "UPPER" | "LOWER" | "TITLE" | "SMALL_CAPS" | "SMALL_CAPS_FORCED")) = v.str("textCase") { + style.insert("textCase".into(), json!(case)); + } for k in ["textAlignHorizontal", "textAlignVertical", "textAutoResize", "textTruncation"] { copy_str(&mut style, v, k, k); } + // Paragraph settings belong to the effective text layer, including explicit zero clears. + for k in ["paragraphSpacing", "paragraphIndent"] { + copy_num(&mut style, v, k, k); + } if let Some(n) = v.num("maxLines").filter(|n| *n >= 1.0) { style.insert("maxLines".into(), json!(n)); } @@ -1117,20 +1138,7 @@ impl<'a> Mapper<'a> { } o.insert("style".into(), Value::Object(style)); let Some(td) = td else { return }; - let ids: Vec = td.get("characterStyleIDs").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).map(|i| i as u64).collect(); - if ids.iter().any(|i| *i != 0) { - let mut table = Obj::new(); - for e in td.get("styleOverrideTable").map(Kv::list).unwrap_or_default() { - let Some(sid) = num(e, "styleID") else { continue }; - let mut s = text_style(&View::of(e), size); - if let Some(fills) = e.get("fillPaints") { - s.insert("fills".into(), Value::Array(fills.list().iter().filter_map(paint).collect())); - } - table.insert((sid as u64).to_string(), Value::Object(s)); - } - o.insert("characterStyleOverrides".into(), json!(ids)); - o.insert("styleOverrideTable".into(), Value::Object(table)); - } + char_styles(o, td, size); let lines: Vec<&str> = td .get("lines") .map(Kv::list) @@ -1318,6 +1326,16 @@ impl<'a> Mapper<'a> { "characters" => { if let Some(t) = prop_text(val) { o.insert("characters".into(), json!(t)); + // Run styles count the chars they were made for: for other chars only + // the value's own apply. + if own_text(v) != Some(t) { + o.remove("characterStyleOverrides"); + o.remove("styleOverrideTable"); + if let Some(tv) = val.get("textValue") { + let size = o.get("style").and_then(|s| s.get("fontSize")).and_then(Value::as_f64); + char_styles(o, tv, size); + } + } } } _ => {} @@ -2026,6 +2044,26 @@ fn variable_value(d: &Kv) -> Option { v.get("textValue").and_then(Kv::str).map(|s| json!(s)) } +/// Per-character styles of text data `td` (`characterStyleIDs` + `styleOverrideTable`) as REST +/// `characterStyleOverrides` + `styleOverrideTable`; `size`: the base font size. +fn char_styles(o: &mut Obj, td: &Kv, size: Option) { + let ids: Vec = td.get("characterStyleIDs").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).map(|i| i as u64).collect(); + if ids.iter().all(|i| *i == 0) { + return; + } + let mut table = Obj::new(); + for e in td.get("styleOverrideTable").map(Kv::list).unwrap_or_default() { + let Some(sid) = num(e, "styleID") else { continue }; + let mut s = text_style(&View::of(e), size); + if let Some(fills) = e.get("fillPaints") { + s.insert("fills".into(), Value::Array(fills.list().iter().filter_map(paint).collect())); + } + table.insert((sid as u64).to_string(), Value::Object(s)); + } + o.insert("characterStyleOverrides".into(), json!(ids)); + o.insert("styleOverrideTable".into(), Value::Object(table)); +} + /// The text of a component property value. fn prop_text<'k>(v: &'k Kv) -> Option<&'k str> { v.get("textValue").and_then(|t| t.get("characters")).and_then(Kv::str) @@ -2063,7 +2101,11 @@ fn text_style(v: &View, size: Option) -> Obj { let val = num(ls, "value").unwrap_or(0.0); let px = match ls.get("units").and_then(Kv::str) { Some("PIXELS") => val, - Some("PERCENT") => val / 100.0 * font_size, + Some("PERCENT") => { + // Private (read by `rest/text.rs`): REST spacing is in pixels; keep the percent. + s.insert("_letterSpacingPercent".into(), json!(val)); + val / 100.0 * font_size + } _ => val * font_size, }; s.insert("letterSpacing".into(), json!(px)); diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 24f304a3..81457f8d 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -24,6 +24,8 @@ mod network; mod source_identity_tests; #[cfg(test)] mod tests; +#[cfg(test)] +mod text_style_tests; use std::io::Read; diff --git a/crates/format/src/fig/text_style_tests.rs b/crates/format/src/fig/text_style_tests.rs new file mode 100644 index 00000000..a5f135e7 --- /dev/null +++ b/crates/format/src/fig/text_style_tests.rs @@ -0,0 +1,292 @@ +//! Text layers and their shared text styles on import: which of the style, the layer's own +//! values and an instance's overrides win, on messages built in code. + +use figcraft_doc::{Document, LetterSpacing, StyleKind, TextCase, TextDecoration}; +use serde_json::Value; + +use super::kiwi::Kv; + +type Fields = Vec<(&'static str, Kv<'static>)>; + +const NO_STYLE: u64 = 4_294_967_295; + +fn object(fields: Fields) -> Kv<'static> { + Kv::Object(fields) +} + +fn guid(id: u64) -> Kv<'static> { + object(vec![("sessionID", Kv::Uint(9)), ("localID", Kv::Uint(id))]) +} + +fn link(id: u64) -> Kv<'static> { + object(vec![("guid", guid(id))]) +} + +fn node(id: u64, ty: &'static str, name: &str, parent: Option, mut more: Fields) -> Kv<'static> { + let mut fields = vec![("guid", guid(id)), ("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))]; + if let Some(parent) = parent { + fields.push(("parentIndex", object(vec![("guid", guid(parent)), ("position", Kv::Str("a".into()))]))); + } + fields.append(&mut more); + object(fields) +} + +fn font(family: &str) -> Kv<'static> { + object(vec![("family", Kv::Str(family.into())), ("style", Kv::Str("Regular".into())), ("postscript", Kv::Str(String::new()))]) +} + +fn chars(text: &str) -> Kv<'static> { + object(vec![("characters", Kv::Str(text.into()))]) +} + +/// The REST JSON of a page holding `nodes`. +fn rest(nodes: Vec>) -> Value { + let base = vec![node(1, "DOCUMENT", "Document", None, vec![]), node(2, "CANVAS", "Page", Some(1), vec![])]; + let message = object(vec![("nodeChanges", Kv::Array(base.into_iter().chain(nodes).collect()))]); + super::map::to_rest(&message, "Synthetic text").0 +} + +fn import(value: &Value) -> Document { + let (doc, warnings) = crate::rest::import_fig(value, &|_| None).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + doc +} + +/// Depth-first search for a REST node by name below `v`. +fn find<'v>(v: &'v Value, name: &str) -> Option<&'v Value> { + if v["name"] == name { + return Some(v); + } + v["children"].as_array()?.iter().find_map(|c| find(c, name)) +} + +/// A shared text style (Inter, 20 px, with `shared` fields) and a layer linking it whose own +/// copy of the typography is stale (40 px, another family) and which sets `local`. +fn linked(shared: Fields, local: Fields) -> Document { + let mut definition = vec![("styleType", Kv::Enum("TEXT")), ("fontName", font("Inter")), ("fontSize", Kv::Float(20.0))]; + definition.extend(shared); + let mut layer = vec![("fontName", font("Stale copy")), ("fontSize", Kv::Float(40.0)), ("styleIdForText", link(10)), ("textData", chars("a\nb"))]; + layer.extend(local); + import(&rest(vec![node(10, "TEXT", "Shared", Some(2), definition), node(11, "TEXT", "Label", Some(2), layer)])) +} + +struct Seen { + case: TextCase, + decoration: TextDecoration, + spacing: f64, + indent: f64, + shared_case: TextCase, +} + +fn seen(doc: &Document) -> Seen { + let label = doc.nodes().find(|n| n.name == "Label").unwrap(); + let text = label.kind.text().unwrap(); + // The shared style's typography beats the layer's stale copy, whatever the layer sets locally. + assert_eq!((text.style.font_family.as_str(), text.style.font_size), ("Inter", 20.0)); + let linked = doc.style(label.styles.text.as_deref().unwrap()).unwrap(); + assert_eq!(linked.name, "Shared"); + let StyleKind::Text { style } = &linked.kind else { panic!("the linked definition must stay a text style") }; + Seen { + case: text.style.case, + decoration: text.style.decoration, + spacing: text.paragraph_spacing, + indent: text.paragraph_indent, + shared_case: style.case, + } +} + +#[test] +fn local_case_decoration_and_paragraph_values_beat_the_shared_style() { + let shared = vec![ + ("textCase", Kv::Enum("UPPER")), + ("textDecoration", Kv::Enum("UNDERLINE")), + ("paragraphSpacing", Kv::Float(24.0)), + ("paragraphIndent", Kv::Float(12.0)), + ]; + // Nothing set on the layer: the style's values stay. + let s = seen(&linked(shared.clone(), vec![])); + assert_eq!((s.case, s.decoration, s.spacing, s.indent), (TextCase::Upper, TextDecoration::Underline, 24.0, 12.0)); + // Explicit values on the layer win, and "original", "none" and zero clear the style's. + let cleared = vec![ + ("textCase", Kv::Enum("ORIGINAL")), + ("textDecoration", Kv::Enum("NONE")), + ("paragraphSpacing", Kv::Float(0.0)), + ("paragraphIndent", Kv::Float(0.0)), + ]; + let s = seen(&linked(shared.clone(), cleared)); + assert_eq!((s.case, s.decoration, s.spacing, s.indent), (TextCase::Original, TextDecoration::None, 0.0, 0.0)); + assert_eq!(s.shared_case, TextCase::Upper, "a local value must not change the definition"); + let local = vec![ + ("textCase", Kv::Enum("SMALL_CAPS_FORCED")), + ("textDecoration", Kv::Enum("STRIKETHROUGH")), + ("paragraphSpacing", Kv::Float(6.0)), + ("paragraphIndent", Kv::Float(3.0)), + ]; + let s = seen(&linked(vec![], local)); + assert_eq!((s.case, s.decoration, s.spacing, s.indent), (TextCase::SmallCaps, TextDecoration::Strikethrough, 6.0, 3.0)); + // A value the importer does not know leaves the style's in place. + let s = seen(&linked(shared, vec![("textCase", Kv::Enum("UNSUPPORTED_CASE")), ("textDecoration", Kv::Enum("WAVY"))])); + assert_eq!((s.case, s.decoration), (TextCase::Upper, TextDecoration::Underline)); +} + +#[test] +fn an_instance_override_beats_the_shared_style_and_a_detach_unlinks_it() { + let value = rest(vec![ + node(10, "TEXT", "Body", Some(2), vec![("styleType", Kv::Enum("TEXT")), ("fontName", font("Inter")), ("fontSize", Kv::Float(14.0))]), + node(20, "SYMBOL", "Main", Some(2), vec![]), + node( + 21, + "TEXT", + "Label", + Some(20), + vec![("fontName", font("Inter")), ("fontSize", Kv::Float(14.0)), ("styleIdForText", link(10)), ("textData", chars("Hi"))], + ), + node( + 30, + "INSTANCE", + "Inst", + Some(2), + vec![( + "symbolData", + object(vec![ + ("symbolID", guid(20)), + ( + "symbolOverrides", + Kv::Array(vec![object(vec![ + ("guidPath", object(vec![("guids", Kv::Array(vec![guid(21)]))])), + ("fontSize", Kv::Float(30.0)), + ])]), + ), + ]), + )], + ), + // An explicit "no style" detaches, even when the older field still names a style. + node( + 40, + "TEXT", + "Detached", + Some(2), + vec![ + ("fontName", font("Roboto")), + ("fontSize", Kv::Float(40.0)), + ("styleIdForText", object(vec![("guid", object(vec![("sessionID", Kv::Uint(NO_STYLE)), ("localID", Kv::Uint(NO_STYLE))]))])), + ("inheritTextStyleID", guid(10)), + ("textData", chars("ab")), + ], + ), + ]); + let label = find(find(&value["document"], "Inst").unwrap(), "Label").unwrap(); + assert_eq!(label["style"]["fontSize"].as_f64(), Some(30.0), "the override beats the style"); + let detached = find(&value["document"], "Detached").unwrap(); + assert!(detached["styles"].get("text").is_none(), "{}", detached["styles"]); + assert_eq!(detached["style"]["fontSize"].as_f64(), Some(40.0)); +} + +#[test] +fn a_text_property_value_drops_the_run_styles_made_for_the_main_text() { + let ids = [0u64, 0, 0, 1, 1, 1].map(Kv::Uint).to_vec(); + let value = rest(vec![ + node( + 20, + "SYMBOL", + "Main", + Some(2), + vec![( + "componentPropDefs", + Kv::Array(vec![object(vec![ + ("id", guid(70)), + ("name", Kv::Str("Text".into())), + ("type", Kv::Enum("TEXT")), + ("initialValue", object(vec![("textValue", chars("Hi you"))])), + ])]), + )], + ), + node( + 21, + "TEXT", + "Label", + Some(20), + vec![ + ("fontName", font("Inter")), + ("fontSize", Kv::Float(12.0)), + ( + "textData", + object(vec![ + ("characters", Kv::Str("Hi you".into())), + ("characterStyleIDs", Kv::Array(ids)), + ("styleOverrideTable", Kv::Array(vec![object(vec![("styleID", Kv::Uint(1)), ("fontSize", Kv::Float(30.0))])])), + ]), + ), + ( + "parameterConsumptionMap", + object(vec![( + "entries", + Kv::Array(vec![object(vec![ + ("variableField", Kv::Enum("TEXT_DATA")), + ("variableData", object(vec![("value", object(vec![("propRefValue", object(vec![("defId", guid(70))]))]))])), + ])]), + )]), + ), + ], + ), + node( + 30, + "INSTANCE", + "Inst", + Some(2), + vec![ + ("symbolData", object(vec![("symbolID", guid(20))])), + ( + "componentPropAssignments", + Kv::Array(vec![object(vec![("defID", guid(70)), ("value", object(vec![("textValue", chars("Goodbye"))]))])]), + ), + ], + ), + ]); + let main = find(find(&value["document"], "Main").unwrap(), "Label").unwrap(); + let kept: Vec = main["characterStyleOverrides"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); + assert_eq!(kept, [0, 0, 0, 1, 1, 1], "the main keeps its run styles"); + let inst = find(&value["document"], "Inst").unwrap(); + let layer = find(inst, "Label").unwrap(); + assert_eq!(layer["characters"], "Goodbye"); + assert!(layer.get("characterStyleOverrides").is_none(), "positions of the main's runs do not fit the property text"); +} + +#[test] +fn percent_letter_spacing_stays_a_percentage() { + let spacing = |units: &'static str, value: f32| object(vec![("value", Kv::Float(value)), ("units", Kv::Enum(units))]); + let layer = |id, name: &str, letter: Kv<'static>| { + node( + id, + "TEXT", + name, + Some(2), + vec![("fontName", font("Inter")), ("fontSize", Kv::Float(20.0)), ("letterSpacing", letter), ("textData", chars("Hello"))], + ) + }; + let doc = import(&rest(vec![layer(10, "Percent", spacing("PERCENT", 5.0)), layer(11, "Pixels", spacing("PIXELS", 2.0))])); + let spacing = |name: &str| doc.nodes().find(|n| n.name == name).unwrap().kind.text().unwrap().style.letter_spacing; + assert_eq!(spacing("Percent"), LetterSpacing::Percent(5.0)); + assert_eq!(spacing("Pixels"), LetterSpacing::Pixels(2.0)); +} + +#[test] +fn the_private_percent_is_read_for_fig_imports_only() { + let value = rest(vec![node( + 10, + "TEXT", + "Label", + Some(2), + vec![ + ("fontName", font("Inter")), + ("fontSize", Kv::Float(20.0)), + ("letterSpacing", object(vec![("value", Kv::Float(5.0)), ("units", Kv::Enum("PERCENT"))])), + ("textData", chars("Hello")), + ], + )]); + let spacing = |doc: &Document| doc.nodes().find(|n| n.name == "Label").unwrap().kind.text().unwrap().style.letter_spacing; + assert_eq!(spacing(&import(&value)), LetterSpacing::Percent(5.0)); + // The same JSON through the public REST import is pixels. + let (public, _) = crate::rest::import(&value, &|_| None).unwrap(); + assert_eq!(spacing(&public), LetterSpacing::Pixels(1.0)); +} diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index c5381bb2..3875204f 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -106,6 +106,12 @@ fn type_style(cx: &mut Ctx, s: &Value, base: &TextStyle) -> TextStyle { if let Some(ls) = num(s, "letterSpacing") { t.letter_spacing = if ls == 0.0 { LetterSpacing::default() } else { LetterSpacing::Pixels(ls.clamp(-1e4, 1e4)) }; } + // Private, from the .fig mapper: the spacing was a share of the font size. + if cx.fig_sources.is_some() + && let Some(p) = num(s, "_letterSpacingPercent") + { + t.letter_spacing = LetterSpacing::Percent(p.clamp(-1e4, 1e4)); + } match text(s, "textCase") { Some("ORIGINAL") => t.case = TextCase::Original, Some("UPPER") => t.case = TextCase::Upper, From 8538dd740f58e51ce672ac6ff8674673e683cef3 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:38:36 +0200 Subject: [PATCH 011/118] Refuse edits that leave a layer with non-finite geometry Found while testing crates/engine/src/cmd/arrange.rs:132 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/finite_geometry_tests.rs | 40 ++++++++++++++++++++++ crates/engine/src/lib.rs | 21 +++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/finite_geometry_tests.rs diff --git a/crates/engine/src/finite_geometry_tests.rs b/crates/engine/src/finite_geometry_tests.rs new file mode 100644 index 00000000..416b250c --- /dev/null +++ b/crates/engine/src/finite_geometry_tests.rs @@ -0,0 +1,40 @@ +//! Commands never leave a layer with a non-finite position or size: such a file saves but can't be +//! reopened (JSON writes NaN and infinity as null). + +use serde_json::json; + +use crate::Session; + +fn rect(s: &mut Session) -> crate::NodeId { + let r = s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 10, "width": 100, "height": 50})).unwrap(); + crate::params::id_of(&r["id"]).unwrap() +} + +fn assert_roundtrips(s: &Session) { + let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap(); + assert!(figcraft_format::load_bytes(&bytes).is_ok(), "the saved file reopens"); +} + +#[test] +fn huge_moves_are_refused_and_the_file_still_reopens() { + let mut s = Session::new(); + let r = rect(&mut s); + s.execute("transform.move", &json!({"ids": [r], "dx": 1e308, "dy": 0})).ok(); + let second = s.execute("transform.move", &json!({"ids": [r], "dx": 1e308, "dy": 1e308})); + assert!(second.is_err(), "a move past the f64 range is refused"); + let n = s.doc().unwrap().node(r).unwrap(); + assert!(n.transform.as_coeffs().iter().all(|c| c.is_finite()), "{:?}", n.transform); + assert_roundtrips(&s); +} + +#[test] +fn huge_scale_is_refused_and_the_file_still_reopens() { + let mut s = Session::new(); + let r = rect(&mut s); + s.execute("transform.scale", &json!({"ids": [r], "factor": 1e300})).ok(); + let second = s.execute("transform.scale", &json!({"ids": [r], "factor": 1e300})); + assert!(second.is_err(), "a size past the f64 range is refused"); + let n = s.doc().unwrap().node(r).unwrap(); + assert!(n.width.is_finite() && n.height.is_finite(), "{} x {}", n.width, n.height); + assert_roundtrips(&s); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..18498146 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -878,7 +878,8 @@ impl Session { let result = guard::run(id, || (spec.run)(self, params)) .and_then(|v| self.check_instance_structure(id, &before).map(|()| v)) .and_then(|v| self.check_preview(id, &before).map(|()| v)) - .and_then(|v| self.check_live_access(id, &before).map(|()| v)); + .and_then(|v| self.check_live_access(id, &before).map(|()| v)) + .and_then(|v| self.check_finite_geometry(id, &before).map(|()| v)); match &result { Ok(_) => { self.after_command(spec, before); @@ -916,6 +917,22 @@ impl Session { Err(EngineError::Disabled(id.to_string(), PREVIEW_READ_ONLY.into())) } + /// A layer's position and size stay finite: infinity and NaN are written as `null`, so a file + /// holding them saves but can't be reopened. Refuse such an edit (it is rolled back). + fn check_finite_geometry(&self, id: &str, before: &Option<(Arc, Vec, NodeId, u64)>) -> Result<()> { + let Some((bdoc, _, _, uid)) = before else { return Ok(()) }; + let Some(ds) = self.docs.iter().find(|d| d.uid == *uid) else { return Ok(()) }; + if Arc::ptr_eq(&ds.doc, bdoc) { + return Ok(()); + } + let broken = |n: &Node| !(n.transform.as_coeffs().iter().all(|c| c.is_finite()) && n.width.is_finite() && n.height.is_finite()); + let untouched = |n: &Node| bdoc.node_arc(n.id).zip(ds.doc.node_arc(n.id)).is_some_and(|(a, b)| Arc::ptr_eq(a, b)); + match ds.doc.nodes().find(|n| broken(n) && !untouched(n)) { + Some(n) => Err(bad(id, format!("\"{}\" would get a position or size beyond the number range", n.name))), + None => Ok(()), + } + } + /// A live session's document you may only view (or that is on its way to a server) is /// read-only: refuse an edit (it is rolled back). fn check_live_access(&self, id: &str, before: &Option<(Arc, Vec, NodeId, u64)>) -> Result<()> { @@ -1528,6 +1545,8 @@ mod fig_export_tests; #[cfg(test)] mod fig_import_tests; #[cfg(test)] +mod finite_geometry_tests; +#[cfg(test)] mod frame_guides_tests; #[cfg(test)] mod frame_label_tests; From 0b409642022daea63ab33868f563c388c47d3627 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:32:56 +0200 Subject: [PATCH 012/118] M1: keep grid track sizes in .fig import Grid columns and rows keep fixed pixel sizes, Hug sizing and fractional Fill weights when the minimum and maximum sizing definitions agree. Track GUIDs join the sizing records to the ordered tracks, so reordered records do not change cell placement. Unsupported min/max pairs, invalid sizes and duplicate records fall back with a reported note, and imports are bounded to 1,000 tracks per axis. Public REST snapshots ignore the private track metadata. --- crates/format/src/fig/map.rs | 104 +++++++++++++++++--- crates/format/src/fig/tests.rs | 169 ++++++++++++++++++++++++++++++++ crates/format/src/rest/mod.rs | 2 +- crates/format/src/rest/props.rs | 29 +++++- crates/format/src/rest/tests.rs | 2 + 5 files changed, 290 insertions(+), 16 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 0fe44662..92d7ff75 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -23,6 +23,8 @@ const MAX_NODES: usize = crate::rest::MAX_NODES; const MAX_DEPTH: usize = 256; /// Most path commands read from one geometry blob. const MAX_COMMANDS: usize = 200_000; +const MAX_GRID_TRACKS: usize = 1_000; +const MAX_GRID_TRACK_VALUE: f64 = 1e6; /// Id of "no style". const NO_STYLE: &str = "4294967295:4294967295"; /// Longest asset `key` or `version` kept for resolving references given as assets. @@ -1089,21 +1091,75 @@ impl<'a> Mapper<'a> { copy_num(o, v, "gridRowGap", "gridRowGap"); o.insert("gridColumnCount".into(), json!(tracks(v, "gridColumns").len())); o.insert("gridRowCount".into(), json!(tracks(v, "gridRows").len())); - let sizes = ["gridColumnsSizing", "gridRowsSizing"] - .iter() - .flat_map(|k| v.get(k).and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default()); - let flexible = sizes.into_iter().all(|e| { - let t = e.get("trackSize"); - ["minSizing", "maxSizing"] - .iter() - .all(|s| t.and_then(|t| t.get(s)).and_then(|s| s.get("type")).and_then(Kv::str).is_none_or(|x| x == "FLEX")) - }); - if !flexible { - self.warn("grid tracks of fixed or content size import as equal tracks"); + let track_data = [("columnTracks", "gridColumns", "gridColumnsSizing"), ("rowTracks", "gridRows", "gridRowsSizing")] + .into_iter() + .filter_map(|(out_key, order_key, sizing_key)| { + self.map_grid_tracks(v, order_key, sizing_key).map(|mapped| (out_key.to_string(), mapped)) + }) + .collect::(); + if !track_data.is_empty() { + let source = o.entry(crate::rest::FIG_SOURCE_FIELD).or_insert_with(|| json!({})); + if let Some(source) = source.as_object_mut() { + source.insert("gridTracks".into(), Value::Object(track_data)); + } } } } + /// Track sizing maps are keyed by GUID; their order comes from the separate track list. + fn map_grid_tracks(&mut self, v: &View<'a>, order_key: &str, sizing_key: &str) -> Option { + let track_entries = v.get(order_key).and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default(); + let sizes = v.get(sizing_key).and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default(); + if track_entries.len() > MAX_GRID_TRACKS { + self.warn("grid track list exceeds 1000 entries; the tracks use the default equal sizing"); + return None; + } + if sizes.is_empty() { + return None; + } + if sizes.len() > MAX_GRID_TRACKS { + self.warn("grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing"); + return None; + } + let ordered = tracks(v, order_key); + let ordered_ids: BTreeSet<&str> = ordered.iter().map(String::as_str).collect(); + let mut size_by_id: BTreeMap>> = BTreeMap::new(); + for entry in sizes { + let Some(id) = entry.get("id").and_then(guid) else { + self.warn("stale or invalid grid track sizing entries were ignored"); + continue; + }; + if !ordered_ids.contains(id.as_str()) { + self.warn("stale or invalid grid track sizing entries were ignored"); + continue; + } + match size_by_id.entry(id) { + std::collections::btree_map::Entry::Vacant(slot) => { + slot.insert(Some(entry)); + } + std::collections::btree_map::Entry::Occupied(mut slot) => { + slot.insert(None); + self.warn("duplicate grid track sizing entries use equal tracks"); + } + } + } + let mut mapped = Vec::with_capacity(ordered.len()); + for id in ordered { + if let Some(Some(entry)) = size_by_id.get(&id) { + match grid_track_sizing(entry.get("trackSize")) { + Some(track) => mapped.push(track), + None => { + self.warn("grid track sizing is unsupported or malformed; affected tracks use equal sizing"); + mapped.push(json!({"sizing": "fill", "value": 1.0})); + } + } + } else { + mapped.push(json!({"sizing": "fill", "value": 1.0})); + } + } + Some(Value::Array(mapped)) + } + fn text(&mut self, o: &mut Obj, v: &View<'a>) { let style_node = style_link(v, "styleIdForText", "inheritTextStyleID").and_then(|s| self.nodes.get(&s).copied()); // Instance overrides beat the shared style; the style beats the layer's own copy. @@ -2018,6 +2074,32 @@ fn tracks(v: &View, key: &str) -> Vec { t.into_iter().map(|e| e.1).collect() } +/// Convert only unconstrained tracks, whose min and max sizing functions agree. +fn grid_track_sizing(size: Option<&Kv<'_>>) -> Option { + let size = size?; + let min = size.get("minSizing")?; + let max = size.get("maxSizing")?; + let min_type = min.get("type").and_then(Kv::str)?; + let max_type = max.get("type").and_then(Kv::str)?; + if min_type != max_type { + return None; + } + match min_type { + "FIXED" => { + let px = num(min, "value")?; + let max_px = num(max, "value")?; + (px == max_px && px.is_finite() && (0.0..=MAX_GRID_TRACK_VALUE).contains(&px)).then(|| json!({"sizing": "fixed", "value": px})) + } + "FLEX" => { + let fr = num(min, "value")?; + let max_fr = num(max, "value")?; + (fr == max_fr && fr.is_finite() && fr > 0.0 && fr <= MAX_GRID_TRACK_VALUE).then(|| json!({"sizing": "fill", "value": fr})) + } + "HUG" => Some(json!({"sizing": "hug", "value": 0.0})), + _ => None, + } +} + /// The variable an `ALIAS` variable data points at. fn alias(d: Option<&Kv>) -> Option { let d = d?; diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 19c4fc15..65c8ed38 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -390,6 +390,175 @@ fn named<'d>(doc: &'d figcraft_doc::Document, name: &str) -> &'d figcraft_doc::N doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer {name}")) } +/// The shared schema plus the grid sizing definitions only these tests use. +fn grid_schema() -> Schema { + const NODE_FIELDS: &str = ", GridTrackMap gridColumns, GridTrackMap gridRows, GridTrackSizeMap gridColumnsSizing, GridTrackSizeMap gridRowsSizing, GUID gridColumnAnchor, GUID gridRowAnchor, uint gridColumnSpan, uint gridRowSpan, float gridColumnGap, float gridRowGap"; + const TYPES: &str = " +enum GridTrackSizingType FLEX FIXED HUG UNKNOWN +message GridTrackSizeFunction GridTrackSizingType type, float value +message GridTrackSize GridTrackSizeFunction minSizing, GridTrackSizeFunction maxSizing +message GridTrackSizeEntry GUID id, GridTrackSize trackSize +message GridTrackSizeMap GridTrackSizeEntry[] entries +message GridTrackEntry GUID id, string position +message GridTrackMap GridTrackEntry[] entries +message Blob "; + let text = SCHEMA.replace("enum StackMode NONE HORIZONTAL VERTICAL\n", "enum StackMode NONE HORIZONTAL VERTICAL GRID\n").replacen( + "\nmessage Blob ", + &format!("{NODE_FIELDS}{TYPES}"), + 1, + ); + Schema::parse(&text).unwrap() +} + +fn track_size(kind: &'static str, value: f64) -> Kv<'static> { + let sizing = || o(vec![("type", e(kind)), ("value", f(value))]); + o(vec![("minSizing", sizing()), ("maxSizing", sizing())]) +} + +fn ranged_track_size(min: Kv<'static>, max: Kv<'static>) -> Kv<'static> { + o(vec![("minSizing", min), ("maxSizing", max)]) +} + +fn track_map_entry(id: u64, size: Kv<'static>) -> Kv<'static> { + o(vec![("id", g(id)), ("trackSize", size)]) +} + +fn positioned_track(id: u64, position: &'static str) -> Kv<'static> { + o(vec![("id", g(id)), ("position", s(position))]) +} + +fn unpositioned_track(id: u64) -> Kv<'static> { + o(vec![("id", g(id))]) +} + +#[test] +fn grid_tracks_import_by_guid_and_keep_track_order() { + let cols = arr(vec![positioned_track(13, "$"), unpositioned_track(10), positioned_track(12, "#"), unpositioned_track(11)]); + let rows = arr(vec![positioned_track(21, "\""), positioned_track(20, "!")]); + // Sizing entries intentionally differ from track order. Their GUIDs define the join. + let col_sizes = arr(vec![ + track_map_entry(12, track_size("FLEX", 1.0)), + track_map_entry(10, track_size("FIXED", 120.0)), + track_map_entry(13, track_size("FLEX", 2.0)), + track_map_entry(11, track_size("HUG", 80.0)), + ]); + let row_sizes = arr(vec![track_map_entry(21, track_size("HUG", 30.0)), track_map_entry(20, track_size("FIXED", 50.0))]); + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + node( + 2, + Some((1, "!")), + "FRAME", + "Grid", + vec![ + ("size", xy(600.0, 300.0)), + ("transform", at(0.0, 0.0)), + ("stackMode", e("GRID")), + ("gridColumns", o(vec![("entries", cols)])), + ("gridRows", o(vec![("entries", rows)])), + ("gridColumnsSizing", o(vec![("entries", col_sizes)])), + ("gridRowsSizing", o(vec![("entries", row_sizes)])), + ("gridColumnGap", f(10.0)), + ("gridRowGap", f(12.0)), + ], + ), + node( + 3, + Some((2, "!")), + "RECTANGLE", + "Hug child", + vec![ + ("size", xy(80.0, 30.0)), + ("transform", at(0.0, 0.0)), + ("gridColumnAnchor", g(11)), + ("gridRowAnchor", g(21)), + ("gridColumnSpan", u(2)), + ("gridRowSpan", u(1)), + ], + ), + ]; + let bytes = canvas(&grid_schema(), &o(vec![("nodeChanges", arr(nodes))])); + let (doc, warnings) = import(&bytes, "Grid tracks").unwrap(); + let grid = named(&doc, "Grid").layout.as_ref().unwrap(); + assert_eq!((grid.mode, grid.grid_columns, grid.grid_rows, grid.gap, grid.cross_gap), (figcraft_doc::LayoutMode::Grid, 4, 2, 10.0, 12.0)); + assert_eq!( + grid.column_tracks, + vec![ + figcraft_doc::GridTrack::fixed(120.0), + figcraft_doc::GridTrack::hug(), + figcraft_doc::GridTrack::fill(1.0), + figcraft_doc::GridTrack::fill(2.0), + ] + ); + assert_eq!(grid.row_tracks, vec![figcraft_doc::GridTrack::fixed(50.0), figcraft_doc::GridTrack::hug()]); + let child = &named(&doc, "Hug child").child_layout; + assert_eq!((child.grid_column, child.grid_row, child.grid_column_span, child.grid_row_span), (Some(1), Some(1), 2, 1)); + assert!(!warnings.iter().any(|w| w.contains("grid track")), "{warnings:?}"); +} + +#[test] +fn malformed_grid_tracks_warn_and_keep_equal_track_fallbacks() { + let tracks = arr(vec![positioned_track(30, "!"), positioned_track(31, "\""), positioned_track(32, "#"), positioned_track(33, "$")]); + let sizing = arr(vec![ + track_map_entry(30, track_size("FIXED", -1.0)), + track_map_entry(31, ranged_track_size(o(vec![("type", e("FLEX")), ("value", f(1.0))]), o(vec![("type", e("FLEX")), ("value", f(2.0))]))), + track_map_entry(33, track_size("UNKNOWN", 5.0)), + track_map_entry(34, track_size("FLEX", 3.0)), + ]); + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + node( + 2, + Some((1, "!")), + "FRAME", + "Grid", + vec![ + ("size", xy(100.0, 100.0)), + ("transform", at(0.0, 0.0)), + ("stackMode", e("GRID")), + ("gridColumns", o(vec![("entries", tracks)])), + ("gridColumnsSizing", o(vec![("entries", sizing)])), + ], + ), + ]; + let bytes = canvas(&grid_schema(), &o(vec![("nodeChanges", arr(nodes))])); + let (doc, warnings) = import(&bytes, "Malformed grid tracks").unwrap(); + let layout = named(&doc, "Grid").layout.as_ref().unwrap(); + assert_eq!(layout.column_tracks, vec![figcraft_doc::GridTrack::default(); 4]); + assert!(warnings.iter().any(|w| w.contains("grid track sizing is unsupported or malformed")), "{warnings:?}"); + assert!(warnings.iter().any(|w| w.contains("stale or invalid grid track sizing entries")), "{warnings:?}"); +} + +#[test] +fn oversized_grid_track_lists_warn_and_use_default_tracks() { + let oversized: Vec> = (0..1_001).map(|i| unpositioned_track(100 + i)).collect(); + let sizing = vec![track_map_entry(100, track_size("FIXED", 40.0))]; + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + node( + 2, + Some((1, "!")), + "FRAME", + "Grid", + vec![ + ("size", xy(100.0, 100.0)), + ("transform", at(0.0, 0.0)), + ("stackMode", e("GRID")), + ("gridColumns", o(vec![("entries", arr(oversized))])), + ("gridColumnsSizing", o(vec![("entries", arr(sizing))])), + ], + ), + ]; + let bytes = canvas(&grid_schema(), &o(vec![("nodeChanges", arr(nodes))])); + let (doc, warnings) = import(&bytes, "Oversized grid tracks").unwrap(); + let layout = named(&doc, "Grid").layout.as_ref().unwrap(); + assert!(layout.column_tracks.is_empty(), "{:?}", layout.column_tracks.len()); + assert!(warnings.iter().any(|w| w.contains("grid track list exceeds 1000 entries")), "{warnings:?}"); +} + #[test] fn kiwi_round_trip() { let sc = schema(); diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 282c6a75..4aa252a7 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -599,7 +599,7 @@ impl<'a> Ctx<'a> { if let Some(c) = flag(j, "clipsContent") { n.clip = c; } - n.layout = props::auto_layout(j); + n.layout = props::auto_layout(j, self.fig_sources.is_some()); n.grids = props::layout_grids(j); } n.child_layout = props::child_layout(j, n.layout.as_ref().map(|l| l.mode), parent_mode); diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index 67ec8063..1f8fdd19 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -5,8 +5,8 @@ use figcraft_doc::color::{BlendMode, Color, ColorStop, Gradient, GradientKind, I use figcraft_doc::geom::Affine; use figcraft_doc::{ AutoLayout, Blur, BlurType, CellAlign, ChildLayout, Constraints, ContentAlign, CrossAlign, Effect, EffectKind, ExportFormat, ExportSetting, - ExportSize, Glass, GridAlign, GridKind, HConstraint, LayoutGrid, LayoutMode, MainAlign, Noise, NoiseMode, Shadow, Sizing, StrokeAlign, StrokeCap, - StrokeJoin, StrokeProps, Texture, VConstraint, WidthPoint, WidthProfile, + ExportSize, Glass, GridAlign, GridKind, GridTrack, HConstraint, LayoutGrid, LayoutMode, MainAlign, Noise, NoiseMode, Shadow, Sizing, StrokeAlign, + StrokeCap, StrokeJoin, StrokeProps, Texture, VConstraint, WidthPoint, WidthProfile, }; use serde_json::Value; @@ -394,7 +394,7 @@ pub(super) fn constraints(v: &Value) -> Option { // ---- auto layout ---- /// The auto layout of a frame-like node (`layoutMode` other than `NONE`). -pub(super) fn auto_layout(v: &Value) -> Option { +pub(super) fn auto_layout(v: &Value, fig_tracks: bool) -> Option { let mode = match text(v, "layoutMode")? { "HORIZONTAL" => LayoutMode::Horizontal, "VERTICAL" => LayoutMode::Vertical, @@ -432,10 +432,31 @@ pub(super) fn auto_layout(v: &Value) -> Option { grid_rows: count("gridRowCount", 2), strokes_included: flag(v, "strokesIncludedInLayout").unwrap_or(false), reverse_z: flag(v, "itemReverseZIndex").unwrap_or(false), - ..AutoLayout::default() + column_tracks: if fig_tracks && grid { fig_grid_tracks(v, "columnTracks") } else { Vec::new() }, + row_tracks: if fig_tracks && grid { fig_grid_tracks(v, "rowTracks") } else { Vec::new() }, }) } +fn fig_grid_tracks(v: &Value, axis: &str) -> Vec { + v.get(crate::rest::FIG_SOURCE_FIELD) + .and_then(|source| source.get("gridTracks")) + .and_then(|tracks| tracks.get(axis)) + .and_then(Value::as_array) + .into_iter() + .flatten() + .take(1_000) + .map(|track| { + let value = track.get("value").and_then(Value::as_f64).filter(|n| n.is_finite() && (0.0..=MAX_SIZE).contains(n)); + match (track.get("sizing").and_then(Value::as_str), value) { + (Some("fixed"), Some(px)) => GridTrack::fixed(px), + (Some("fill"), Some(fr)) if fr > 0.0 => GridTrack::fill(fr), + (Some("hug"), _) => GridTrack::hug(), + _ => GridTrack::default(), + } + }) + .collect() +} + /// How the node sizes and sits in its parent: its own hug/fixed axes (auto layout frames), its /// fill/fixed axes as a child of an auto layout `parent_mode`, absolute positioning, min/max sizes /// and grid cells. diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index d7117fd0..79956338 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -363,6 +363,7 @@ fn auto_layout_constraints_and_grids() { ]}, {"id": "4", "name": "Grid", "type": "FRAME", "relativeTransform": at(200.0, 0.0), "size": {"x": 300, "y": 200}, "layoutMode": "GRID", "gridColumnCount": 3, "gridRowCount": 2, "gridColumnGap": 4, "gridRowGap": 6, + "_figSource": {"gridTracks": {"columnTracks": [{"sizing": "fixed", "value": 120}] }}, "children": [{"id": "5", "name": "Cell", "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}, "gridColumnAnchorIndex": 1, "gridRowAnchorIndex": 0, "gridColumnSpan": 2, "gridRowSpan": 1}]} ])); @@ -389,6 +390,7 @@ fn auto_layout_constraints_and_grids() { assert_eq!((abs.constraints.horizontal, abs.constraints.vertical), (HConstraint::LeftRight, VConstraint::Bottom)); let grid = named(&doc, "Grid").layout.clone().unwrap(); assert_eq!((grid.mode, grid.grid_columns, grid.grid_rows, grid.gap, grid.cross_gap), (LayoutMode::Grid, 3, 2, 4.0, 6.0)); + assert!(grid.column_tracks.is_empty(), "private .fig track metadata is ignored by the public REST importer"); let cell = &named(&doc, "Cell").child_layout; assert_eq!((cell.grid_column, cell.grid_row, cell.grid_column_span, cell.grid_row_span), (Some(1), Some(0), 2, 1)); } From 8408cc72b8db4af3c9408eaa2ced97dc34e7c213 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:40:50 +0200 Subject: [PATCH 013/118] M1: import component slot content from .fig files Slot properties of a component, the frames that reference them and the content that instances assign to them now come across from .fig files. A slot definition becomes a native slot property. The referencing frame of an expanded instance keeps following the frame of its main, and when the instance assigns slot content (including an explicitly empty slot) the frame carries that content as its own, independently owned children. Nested instances inside assigned content keep their own source links. An assignment on an outer instance wins over one made by an instance nested in the main, and a slot without an assignment keeps the default content of its main. Assignments that are malformed, use the empty-GUID sentinel or point at a layer that is not slot content are skipped with an import note. Preferred component keys cannot be mapped to nodes of the file and are dropped with a note, as are slot settings the editor cannot represent. Public REST snapshots, the experimental .fig writer and property resources are not changed. --- crates/format/src/fig/map.rs | 107 +++++++++++- crates/format/src/fig/mod.rs | 2 + crates/format/src/fig/slot_tests.rs | 225 ++++++++++++++++++++++++++ crates/format/src/rest/fig_sources.rs | 11 +- crates/format/src/rest/mod.rs | 8 +- 5 files changed, 346 insertions(+), 7 deletions(-) create mode 100644 crates/format/src/fig/slot_tests.rs diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 0fe44662..6860327f 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -295,6 +295,10 @@ struct Inst<'a> { path: Vec, overrides: Rc>, props: Rc>>, + /// Slot property GUID → private slot-content node GUID. + slot_contents: Rc>, + /// Layers imported from a slot's independent content subtree. + slot_content: bool, /// Mains being expanded (an instance inside its own main stops there). symbols: Vec, } @@ -768,7 +772,13 @@ impl<'a> Mapper<'a> { // Private provenance for the .fig importer; ordinary REST import ignores it. o.insert( crate::rest::FIG_SOURCE_FIELD.into(), - json!({"origin": id, "expanded": inst.is_some(), "wireFrame": own == "FRAME", "wireInstance": own == "INSTANCE"}), + json!({ + "origin": id, + "expanded": inst.is_some(), + "wireFrame": own == "FRAME", + "wireInstance": own == "INSTANCE", + "slotContent": inst.is_some_and(|i| i.slot_content), + }), ); o.insert("type".into(), json!(ty)); copy_str(&mut o, &view, "name", "name"); @@ -797,6 +807,16 @@ impl<'a> Mapper<'a> { self.component(&mut o, &view, &rest_id); } self.prop_refs(&mut o, &view, inst); + let slot_content = if own == "FRAME" { + prop_ref(&view, "SLOT_CONTENT_ID").and_then(|def| inst.and_then(|i| i.slot_contents.get(&def).cloned())) + } else { + None + }; + if slot_content.is_some() + && let Some(source) = o.get_mut(crate::rest::FIG_SOURCE_FIELD).and_then(Value::as_object_mut) + { + source.insert("slotOverride".into(), json!(true)); + } let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { space * view.transform().unwrap_or_default() } else { Affine::IDENTITY }; let mut kids = Vec::new(); if ty == "INSTANCE" { @@ -808,6 +828,14 @@ impl<'a> Mapper<'a> { kids.extend(self.layer_filtered(&c, Some(&sub), child_space, Some(&view), fit, depth + 1, None)); } } + } else if let Some(content) = slot_content { + let mut content_inst = inst.cloned(); + if let Some(content_inst) = &mut content_inst { + content_inst.slot_content = true; + } + for c in self.children(&content) { + kids.extend(self.layer_filtered(&c, content_inst.as_ref(), child_space, Some(&view), None, depth + 1, included)); + } } else { // Inside an instance a layer's children are its main's: they follow it from the main's // size to its own. @@ -1282,6 +1310,11 @@ impl<'a> Mapper<'a> { None => continue, }, "VARIANT" => json!({"type": "VARIANT"}), + "SLOT" => { + self.warn_non_default_slot_config(d); + self.warn_dropped_slot_preferred_values(d); + json!({"type": "SLOT"}) + } _ => { self.warn(format!("{} component properties are not supported and were skipped", ty.to_ascii_lowercase())); continue; @@ -1298,6 +1331,34 @@ impl<'a> Mapper<'a> { } } + /// Preferred slot values are source component keys, but this mapper has no proven way to map + /// those keys to file GUIDs. They are dropped instead of guessing native NodeIds. + fn warn_dropped_slot_preferred_values(&mut self, def: &Kv<'a>) { + let entries = def.get("preferredValues").and_then(|v| v.get("instanceSwapValues")).map(Kv::list).unwrap_or_default(); + if !entries.is_empty() { + self.warn("preferred slot component keys cannot be resolved to file nodes and were skipped"); + } + } + + fn warn_non_default_slot_config(&mut self, def: &Kv<'a>) { + let Some(config) = def.get("slotPropConfig") else { return }; + let non_default = [ + ("stretchChildOnInsert", Some(false)), + ("displayByDefault", Some(false)), + ("minChildren", None), + ("maxChildren", None), + ("allowPreferredValuesOnly", Some(false)), + ] + .into_iter() + .any(|(field, bool_default)| { + let Some(value) = config.get(field) else { return false }; + if let Some(default) = bool_default { value.bool() != Some(default) } else { value.f64() != Some(0.0) } + }); + if non_default { + self.warn("non-default slot settings are not represented and were skipped"); + } + } + /// A layer's component property references, and inside an instance the values they take. fn prop_refs(&mut self, o: &mut Obj, v: &View<'a>, inst: Option<&Inst<'a>>) { let mut refs = Obj::new(); @@ -1306,6 +1367,7 @@ impl<'a> Mapper<'a> { Some("VISIBLE") => "visible", Some("TEXT_DATA") => "characters", Some("OVERRIDDEN_SYMBOL_ID") => "mainComponent", + Some("SLOT_CONTENT_ID") => "slot", _ => continue, }; let Some(def) = @@ -1313,7 +1375,11 @@ impl<'a> Mapper<'a> { else { continue; }; - if let Some((name, _)) = self.prop_defs.get(&def) { + // Expanded frames must follow their main frame's source link. A direct slot reference + // makes the engine resolve ownership against the instance, where the property is not defined. + if !(field == "slot" && inst.is_some()) + && let Some((name, _)) = self.prop_defs.get(&def) + { refs.insert(field.into(), json!(name)); } let Some(val) = inst.and_then(|i| i.props.get(&def)) else { continue }; @@ -1445,15 +1511,50 @@ impl<'a> Mapper<'a> { rows.sort_by_key(|row| (std::cmp::Reverse(row.source_depth), row.ordinal)); } let mut all_props: BTreeMap> = outer.map(|i| (*i.props).clone()).unwrap_or_default(); + let mut slot_contents: BTreeMap = outer.map(|i| (*i.slot_contents).clone()).unwrap_or_default(); for a in assignments { if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), a.get("value")) { all_props.entry(d).or_insert(val); } + let Some(def) = a.get("defID").and_then(guid) else { continue }; + if self.prop_defs.get(&def).is_none_or(|(_, ty)| *ty != "SLOT") { + continue; + } + let var_value = a.get("varValue"); + if var_value.and_then(|v| v.get("dataType")).and_then(Kv::str) != Some("SLOT_CONTENT_ID") { + self.warn("malformed slot content assignment was skipped"); + continue; + } + let target = var_value.and_then(|v| v.get("value")).and_then(|v| v.get("slotContentIdValue")).and_then(|v| v.get("guid")).and_then(guid); + let Some(target) = target else { + self.warn("malformed slot content assignment was skipped"); + continue; + }; + if target == "4294967295:4294967295" { + self.warn("slot content assignment uses the empty GUID sentinel and was skipped"); + continue; + } + if !self.nodes.get(&target).is_some_and(|n| n.get("isSlotContent").and_then(Kv::bool) == Some(true)) { + self.warn("slot content assignment target is missing or is not slot content"); + continue; + } + slot_contents.entry(def).or_insert(target); } let mut symbols = outer.map(|i| i.symbols.clone()).unwrap_or_default(); symbols.push(sym.clone()); let root = outer.map_or_else(|| rest_id.to_string(), |i| i.root.clone()); - Some((Inst { root, path: own_path, overrides: Rc::new(overrides), props: Rc::new(all_props), symbols }, sym)) + Some(( + Inst { + root, + path: own_path, + overrides: Rc::new(overrides), + props: Rc::new(all_props), + slot_contents: Rc::new(slot_contents), + slot_content: false, + symbols, + }, + sym, + )) } fn prototype(&mut self, o: &mut Obj, v: &View<'a>) { diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 81457f8d..ceb13346 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -21,6 +21,8 @@ pub mod kiwi; mod map; mod network; #[cfg(test)] +mod slot_tests; +#[cfg(test)] mod source_identity_tests; #[cfg(test)] mod tests; diff --git a/crates/format/src/fig/slot_tests.rs b/crates/format/src/fig/slot_tests.rs new file mode 100644 index 00000000..670e2f6f --- /dev/null +++ b/crates/format/src/fig/slot_tests.rs @@ -0,0 +1,225 @@ +//! Component slot content from `.fig` files: SLOT definitions, the frames that reference them and +//! the content subtrees that instances assign, on synthetic messages. + +use serde_json::Value; + +use super::kiwi::Kv; +use super::map::to_rest; + +fn g(local: u32) -> Kv<'static> { + Kv::Object(vec![("sessionID", Kv::Uint(9)), ("localID", Kv::Uint(u64::from(local)))]) +} + +fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} + +fn node(local: u32, kind: &'static str, parent: Option, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut all = vec![("guid", g(local)), ("type", Kv::Enum(kind)), ("name", Kv::Str("Synthetic".into()))]; + if let Some(parent) = parent { + all.push(("parentIndex", o(vec![("guid", g(parent)), ("position", Kv::Str("!".into()))]))); + } + all.append(&mut fields); + o(all) +} + +fn mapped(msg: &Kv<'_>) -> Value { + to_rest(msg, "Synthetic resources").0 +} + +fn named_id<'b>(v: &'b Value, id: &str) -> Option<&'b Value> { + if v.get("id").and_then(Value::as_str) == Some(id) { + return Some(v); + } + v.get("children").and_then(Value::as_array).into_iter().flatten().find_map(|v| named_id(v, id)) +} + +fn changes<'a>(msg: &'a mut Kv<'static>) -> &'a mut Vec> { + let Kv::Object(fields) = msg else { panic!() }; + let (_, Kv::Array(rows)) = fields.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap() else { panic!() }; + rows +} + +fn slot_message(content: Option, preferred: bool) -> Kv<'static> { + let slot_ref = o(vec![ + ("variableField", Kv::Enum("SLOT_CONTENT_ID")), + ("variableData", o(vec![("value", o(vec![("propRefValue", o(vec![("defId", g(70))]))]))])), + ]); + let mut instance_fields = vec![("symbolData", o(vec![("symbolID", g(10))]))]; + if let Some(content) = content { + instance_fields.push(( + "componentPropAssignments", + Kv::Array(vec![o(vec![ + ("defID", g(70)), + ("value", o(vec![])), + ( + "varValue", + o(vec![ + ("value", o(vec![("slotContentIdValue", o(vec![("guid", g(content))]))])), + ("dataType", Kv::Enum("SLOT_CONTENT_ID")), + ("resolvedDataType", Kv::Enum("SLOT_CONTENT_ID")), + ]), + ), + ])]), + )); + } + let mut definition = vec![("id", g(70)), ("name", Kv::Str("Content".into())), ("type", Kv::Enum("SLOT"))]; + if preferred { + definition.push(( + "preferredValues", + o(vec![("instanceSwapValues", Kv::Array(vec![o(vec![("type", Kv::Enum("COMPONENT")), ("key", Kv::Str("unresolved-key".into()))])]))]), + )); + } + o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, "DOCUMENT", None, vec![]), + node(2, "CANVAS", Some(1), vec![]), + node(3, "CANVAS", Some(1), vec![("internalOnly", Kv::Bool(true))]), + node(10, "SYMBOL", Some(2), vec![("componentPropDefs", Kv::Array(vec![o(definition)]))]), + node(12, "FRAME", Some(10), vec![("parameterConsumptionMap", o(vec![("entries", Kv::Array(vec![slot_ref]))]))]), + node(13, "RECTANGLE", Some(12), vec![]), + node(20, "INSTANCE", Some(2), instance_fields), + node(30, "FRAME", Some(3), vec![("isSlotContent", Kv::Bool(true))]), + node(31, "RECTANGLE", Some(30), vec![]), + node(32, "ELLIPSE", Some(30), vec![]), + node(33, "FRAME", Some(3), vec![("isSlotContent", Kv::Bool(true))]), + ]), + )]) +} +#[test] +fn slot_definition_reference_and_arbitrary_override_children_are_imported() { + let rest = mapped(&slot_message(Some(30), false)); + let main = named_id(&rest["document"], "9:10").unwrap(); + assert_eq!(main["componentPropertyDefinitions"]["Content#9:70"]["type"], "SLOT"); + let main_slot = named_id(&rest["document"], "9:12").unwrap(); + assert_eq!(main_slot["componentPropertyReferences"]["slot"], "Content#9:70"); + let slot = named_id(&rest["document"], "I9:20;9:12").unwrap(); + assert!(slot.get("componentPropertyReferences").is_none()); + assert_eq!(slot[crate::rest::FIG_SOURCE_FIELD]["slotOverride"], true); + let kids = slot["children"].as_array().unwrap(); + assert_eq!(kids.len(), 2); + assert_eq!(kids[0]["id"], "I9:20;9:31"); + assert_eq!(kids[1]["id"], "I9:20;9:32"); + + let (doc, warnings) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let main = doc + .node(*doc.pages.first().unwrap()) + .unwrap() + .children + .iter() + .find_map(|id| doc.node(*id).filter(|n| n.name == "Synthetic" && matches!(n.kind, figcraft_doc::NodeKind::Component { .. }))) + .unwrap(); + assert!( + matches!(&main.kind, figcraft_doc::NodeKind::Component { props, .. } if matches!(props.get("Content"), Some(figcraft_doc::PropDef::Slot { preferred }) if preferred.is_empty())) + ); + let instance = doc.ids().filter_map(|id| doc.node(id)).find(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })).unwrap(); + let slot = doc.node(instance.children[0]).unwrap(); + assert!(!slot.prop_refs.contains_key("slot")); + assert_eq!(slot.source, Some(main.children[0])); + assert!(slot.overrides.contains("slot")); + assert_eq!(slot.children.len(), 2); + assert!(slot.children.iter().all(|id| doc.node(*id).is_some_and(|child| child.source.is_none()))); + + let (public, _) = crate::rest::import(&rest, &crate::rest::no_images).unwrap(); + let public_instance = public.ids().filter_map(|id| public.node(id)).find(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })).unwrap(); + let public_slot = public.node(public_instance.children[0]).unwrap(); + assert!(!public_slot.overrides.contains("slot")); +} +#[test] +fn nested_instance_in_own_slot_content_keeps_its_own_source_tree() { + let mut msg = slot_message(Some(30), false); + let rows = changes(&mut msg); + let slot_frame = rows.iter_mut().find(|row| row.get("guid").and_then(|guid| guid.get("localID")).and_then(Kv::f64) == Some(31.0)).unwrap(); + let Kv::Object(fields) = slot_frame else { panic!() }; + fields.iter_mut().find(|(key, _)| *key == "type").unwrap().1 = Kv::Enum("FRAME"); + rows.extend([ + node(34, "INSTANCE", Some(31), vec![("symbolData", o(vec![("symbolID", g(40))]))]), + node(40, "SYMBOL", Some(2), vec![]), + node(41, "FRAME", Some(40), vec![]), + node(42, "RECTANGLE", Some(41), vec![]), + ]); + + let rest = mapped(&msg); + let (doc, warnings) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let outer = doc + .ids() + .filter_map(|id| doc.node(id)) + .find(|n| { + matches!(n.kind, figcraft_doc::NodeKind::Instance { .. }) + && !doc.ancestors(n.id).iter().any(|a| doc.node(*a).is_some_and(|p| matches!(p.kind, figcraft_doc::NodeKind::Instance { .. }))) + }) + .unwrap(); + let slot = doc.node(outer.children[0]).unwrap(); + let own_frame = slot.children.iter().filter_map(|id| doc.node(*id)).find(|n| matches!(n.kind, figcraft_doc::NodeKind::Frame)).unwrap(); + assert!(own_frame.source.is_none()); + + let nested = slot + .children + .iter() + .filter_map(|id| doc.node(*id)) + .flat_map(|n| n.children.iter()) + .filter_map(|id| doc.node(*id)) + .find(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })) + .unwrap(); + let figcraft_doc::NodeKind::Instance { component, .. } = nested.kind else { panic!("not an instance") }; + assert_eq!(nested.source, Some(component)); + let main_frame = doc.node(doc.node(component).unwrap().children[0]).unwrap(); + let expanded_frame = doc.node(nested.children[0]).unwrap(); + assert_eq!(expanded_frame.source, Some(main_frame.id)); + assert_eq!(expanded_frame.children.len(), 1); + assert_eq!(doc.node(expanded_frame.children[0]).unwrap().source, Some(main_frame.children[0])); +} +#[test] +fn outer_slot_assignment_wins_over_a_nested_instances_own() { + let mut msg = slot_message(Some(30), false); + let assignment = |content: u32| { + Kv::Array(vec![o(vec![ + ("defID", g(70)), + ("value", o(vec![])), + ( + "varValue", + o(vec![ + ("value", o(vec![("slotContentIdValue", o(vec![("guid", g(content))]))])), + ("dataType", Kv::Enum("SLOT_CONTENT_ID")), + ("resolvedDataType", Kv::Enum("SLOT_CONTENT_ID")), + ]), + ), + ])]) + }; + // The nested instance inside the main assigns the empty slot content; the outer instance's content wins. + let slot_ref = o(vec![ + ("variableField", Kv::Enum("SLOT_CONTENT_ID")), + ("variableData", o(vec![("value", o(vec![("propRefValue", o(vec![("defId", g(70))]))]))])), + ]); + changes(&mut msg).extend([ + node(14, "INSTANCE", Some(10), vec![("symbolData", o(vec![("symbolID", g(40))])), ("componentPropAssignments", assignment(33))]), + node(40, "SYMBOL", Some(2), vec![]), + node(41, "FRAME", Some(40), vec![("parameterConsumptionMap", o(vec![("entries", Kv::Array(vec![slot_ref]))]))]), + ]); + let rest = mapped(&msg); + let slot = named_id(&rest["document"], "I9:20;9:14;9:41").unwrap(); + assert_eq!(slot["children"].as_array().map_or(0, Vec::len), 2, "{slot}"); +} +#[test] +fn unassigned_slot_keeps_default_and_bad_content_target_warns() { + let rest = mapped(&slot_message(None, false)); + let slot = named_id(&rest["document"], "I9:20;9:12").unwrap(); + assert_eq!(slot["children"].as_array().unwrap().len(), 1); + assert!(slot.get("overrides").is_none()); + + let (rest, warnings) = to_rest(&slot_message(Some(999), false), "Synthetic"); + assert!(warnings.iter().any(|w| w.contains("slot content assignment target is missing or is not slot content")), "{warnings:?}"); + let slot = named_id(&rest["document"], "I9:20;9:12").unwrap(); + assert_eq!(slot["children"].as_array().unwrap().len(), 1); + + let (rest, warnings) = to_rest(&slot_message(Some(33), true), "Synthetic"); + let main = named_id(&rest["document"], "9:10").unwrap(); + assert_eq!(main["componentPropertyDefinitions"]["Content#9:70"]["type"], "SLOT"); + assert!(warnings.iter().any(|w| w.contains("preferred slot component keys cannot be resolved")), "{warnings:?}"); + let empty = named_id(&rest["document"], "I9:20;9:12").unwrap(); + assert_eq!(empty["children"].as_array().map_or(0, Vec::len), 0); + assert_eq!(empty[crate::rest::FIG_SOURCE_FIELD]["slotOverride"], true); +} diff --git a/crates/format/src/rest/fig_sources.rs b/crates/format/src/rest/fig_sources.rs index 61097e52..2793f932 100644 --- a/crates/format/src/rest/fig_sources.rs +++ b/crates/format/src/rest/fig_sources.rs @@ -17,6 +17,7 @@ struct Origin<'a> { rest: &'a str, guid: &'a str, expanded: bool, + slot_content: bool, wire_frame: bool, wire_instance: bool, component: Option<&'a str>, @@ -75,7 +76,8 @@ impl<'a> Sources<'a> { && meta.get("wireInstance").and_then(Value::as_bool) == Some(true) && node.get("type").and_then(Value::as_str) == Some("INSTANCE"); let component = wire_instance.then(|| node.get("componentId").and_then(Value::as_str).filter(|id| guid(id))).flatten(); - self.nodes.insert(id, Origin { rest, guid: origin, expanded, wire_frame, wire_instance, component }); + let slot_content = meta.get("slotContent").and_then(Value::as_bool).unwrap_or(false); + self.nodes.insert(id, Origin { rest, guid: origin, expanded, slot_content, wire_frame, wire_instance, component }); } fn origin(&self, id: NodeId) -> Option> { @@ -211,6 +213,9 @@ impl<'a> Sources<'a> { let found = (|| { let meta = self.origin(id)?; let _ = doc.node(id)?; + if meta.slot_content { + return if matches!(doc.node(id)?.kind, NodeKind::Instance { .. }) { self.root_source(id, doc) } else { None }; + } if !meta.expanded { return self.root_source(id, doc); } @@ -269,8 +274,8 @@ impl<'a> Sources<'a> { } let mut missing = self.rejected; for (&id, meta) in &self.nodes { - let needs_source = - (meta.expanded && !self.owned_layer(id, doc, ids)) || doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. })); + let needs_source = (!meta.slot_content && meta.expanded && !self.owned_layer(id, doc, ids)) + || doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. })); if !needs_source { continue; } diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 282c6a75..6088519b 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -451,6 +451,10 @@ impl<'a> Ctx<'a> { let id = self.doc.alloc_id(); let label = kind.label(); let mut n = Node::new(id, kind, text(j, "name").map_or(label, clip_str), w, h); + if self.fig_sources.is_some() && j.get(FIG_SOURCE_FIELD).and_then(|source| source.get("slotOverride")).and_then(Value::as_bool) == Some(true) + { + n.overrides.insert("slot".into()); + } n.transform = local(space.world, world); self.common(&mut n, j, parent_mode); let (container, follows, mode) = (n.kind.is_container(), n.kind.bounds_follow_children(), n.layout.as_ref().map(|l| l.mode)); @@ -605,7 +609,8 @@ impl<'a> Ctx<'a> { n.child_layout = props::child_layout(j, n.layout.as_ref().map(|l| l.mode), parent_mode); n.exports = props::export_settings(j); for (field, prop) in j.get("componentPropertyReferences").and_then(Value::as_object).into_iter().flatten() { - if let (Some(p), true) = (prop.as_str(), matches!(field.as_str(), "visible" | "characters" | "mainComponent")) { + let supported = matches!(field.as_str(), "visible" | "characters" | "mainComponent") || (field == "slot" && self.fig_sources.is_some()); + if let (Some(p), true) = (prop.as_str(), supported) { n.prop_refs.insert(field.clone(), p.to_string()); } } @@ -709,6 +714,7 @@ impl<'a> Ctx<'a> { continue; } Some("VARIANT") => continue, + Some("SLOT") if self.fig_sources.is_some() => PropDef::Slot { preferred: Vec::new() }, _ => { self.warn("unknown component property types were skipped"); continue; From a52e2bb6b3773e9ebc328104c887aab2bf58bfdf Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:34:42 +0200 Subject: [PATCH 014/118] M1: distinguish unresolved vector segment style metadata in .fig import The vector network decoder now keeps each segment's style id. Import reports a segment style that names a row of the layer's style table as metadata that was not interpreted, and one that names no row as a reference to a missing style entry, each at most once per file. The earlier note claimed that stroke styles were left out, which overstated what is known about the metadata. Segment geometry, paint and width are unchanged, and the style ids are not kept in the native vector model. --- crates/format/src/fig/export.rs | 8 +++- crates/format/src/fig/map.rs | 25 ++++++++--- crates/format/src/fig/network.rs | 11 ++--- crates/format/src/fig/tail_tests.rs | 64 ++++++++++++++++++++++------- 4 files changed, 83 insertions(+), 25 deletions(-) diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index bfd0b615..7443c8de 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1669,7 +1669,13 @@ impl<'d> Exporter<'d> { let segments = net .segments .iter() - .map(|sg| network::Segment { a: sg.a, ta: (finite(sg.ta.0), finite(sg.ta.1)), b: sg.b, tb: (finite(sg.tb.0), finite(sg.tb.1)) }) + .map(|sg| network::Segment { + style: 0, + a: sg.a, + ta: (finite(sg.ta.0), finite(sg.ta.1)), + b: sg.b, + tb: (finite(sg.tb.0), finite(sg.tb.1)), + }) .collect(); let regions = net .regions diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 0fe44662..05fa8566 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1192,12 +1192,29 @@ impl<'a> Mapper<'a> { fn network(&mut self, o: &mut Obj, v: &View<'a>) -> bool { let Some(vd) = v.get("vectorData") else { return false }; let Some(blob) = num(vd, "vectorNetworkBlob").and_then(|i| self.blobs.get(i as usize)).copied() else { return false }; - let Some((net, styled_segments)) = network::decode_styled(blob) else { + let Some((net, _)) = network::decode_styled(blob) else { self.warn("damaged vector networks import as outlines"); return false; }; - if styled_segments > 0 { - self.warn("stroke styles of single vector network segments were left out"); + let styles: BTreeMap = + vd.get("styleOverrideTable").map(Kv::list).unwrap_or_default().iter().filter_map(|s| Some((num(s, "styleID")? as u64, s))).collect(); + let mut has_uninterpreted = false; + let mut has_missing = false; + for segment in &net.segments { + if segment.style == 0 { + continue; + } + if styles.contains_key(&u64::from(segment.style)) { + has_uninterpreted = true; + } else { + has_missing = true; + } + } + if has_uninterpreted { + self.warn("metadata for styled vector network segments was not interpreted"); + } + if has_missing { + self.warn("vector network segments reference missing style entries"); } // An open path shows its fill, which regions can't hold: the outline keeps the look. let fills_shown = @@ -1205,8 +1222,6 @@ impl<'a> Mapper<'a> { if net.segments.is_empty() || (net.regions.is_empty() && fills_shown && o.contains_key("fillGeometry")) { return false; } - let styles: BTreeMap = - vd.get("styleOverrideTable").map(Kv::list).unwrap_or_default().iter().filter_map(|s| Some((num(s, "styleID")? as u64, s))).collect(); let style = |id: u32| styles.get(&u64::from(id)).copied().filter(|_| id != 0); let scale = |k: &str| { let size = v.size().map(|s| if k == "x" { s.0 } else { s.1 }); diff --git a/crates/format/src/fig/network.rs b/crates/format/src/fig/network.rs index d251dff1..4e8c3ad7 100644 --- a/crates/format/src/fig/network.rs +++ b/crates/format/src/fig/network.rs @@ -17,6 +17,7 @@ pub(super) struct Vertex { } pub(super) struct Segment { + pub style: u32, pub a: usize, pub ta: (f64, f64), pub b: usize, @@ -70,8 +71,7 @@ pub(super) fn decode(b: &[u8]) -> Option { decode_styled(b).map(|(net, _)| net) } -/// The network in blob `b` and how many of its segments have a style id of their own (per-segment -/// stroke overrides, which the document model can't hold); `None` as for `decode`. +/// The network in blob `b` and how many of its segments have a nonzero style id; `None` as for `decode`. pub(super) fn decode_styled(b: &[u8]) -> Option<(Network, usize)> { let mut r = Reader { b, pos: 0 }; let (nv, ns, nr) = (r.u32()? as usize, r.u32()? as usize, r.u32()? as usize); @@ -84,10 +84,11 @@ pub(super) fn decode_styled(b: &[u8]) -> Option<(Network, usize)> { } let mut styled = 0usize; for _ in 0..ns { - if r.u32()? != 0 { + let style = r.u32()?; + if style != 0 { styled += 1; } - net.segments.push(Segment { a: r.index(nv)?, ta: (r.f32()?, r.f32()?), b: r.index(nv)?, tb: (r.f32()?, r.f32()?) }); + net.segments.push(Segment { style, a: r.index(nv)?, ta: (r.f32()?, r.f32()?), b: r.index(nv)?, tb: (r.f32()?, r.f32()?) }); } let mut entries = 0usize; for _ in 0..nr { @@ -120,7 +121,7 @@ pub(super) fn encode(net: &Network) -> Vec { fl(v.y, &mut out); } for s in &net.segments { - u(0, &mut out); + u(s.style, &mut out); u(s.a as u32, &mut out); fl(s.ta.0, &mut out); fl(s.ta.1, &mut out); diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 5d34f23b..657ff6ff 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -9,7 +9,7 @@ use super::super::network::{self, Network, Region, Segment, Vertex}; use super::*; fn seg(a: usize, b: usize, ta: (f64, f64), tb: (f64, f64)) -> Segment { - Segment { a, ta, b, tb } + Segment { style: 0, a, ta, b, tb } } fn vtx(style: u32, x: f64, y: f64) -> Vertex { @@ -264,21 +264,33 @@ fn vectors_import_as_editable_networks() { #[test] fn per_segment_stroke_styles_are_reported() { - let mut blob = square_blob(); - // The first segment's style id follows the counts and the vertices (12 bytes each). - blob[12 + 4 * 12..12 + 4 * 12 + 4].copy_from_slice(&7u32.to_le_bytes()); - let (net, styled) = network::decode_styled(&blob).unwrap(); - assert_eq!((net.segments.len(), styled), (4, 1)); + let styled_import = |segment_style: u32| { + let mut blob = square_blob(); + // The first segment's style id follows the counts and the vertices (12 bytes each). + blob[12 + 4 * 12..12 + 4 * 12 + 4].copy_from_slice(&segment_style.to_le_bytes()); + let (net, styled) = network::decode_styled(&blob).unwrap(); + assert_eq!((net.segments.len(), net.segments[0].style, styled), (4, segment_style, 1)); + let mut message = tail_message(); + let Kv::Object(fields) = &mut message else { panic!() }; + let Kv::Array(blobs) = &mut fields.iter_mut().find(|(key, _)| *key == "blobs").unwrap().1 else { panic!() }; + blobs[1] = o(vec![("bytes", Kv::Bytes(blob))]); + let (doc, warnings) = import(&canvas(&schema(), &message), "Tail").unwrap(); + assert_eq!(network_of(&doc, "Wave").segments.len(), 4); + warnings + }; assert_eq!(network::decode_styled(&square_blob()).unwrap().1, 0); let plain = import(&tail_canvas(), "Tail").unwrap().1; - assert!(!plain.iter().any(|w| w.contains("stroke styles of single")), "{plain:?}"); - let mut message = tail_message(); - let Kv::Object(fields) = &mut message else { panic!() }; - let Kv::Array(blobs) = &mut fields.iter_mut().find(|(key, _)| *key == "blobs").unwrap().1 else { panic!() }; - blobs[1] = o(vec![("bytes", Kv::Bytes(blob))]); - let (doc, warnings) = import(&canvas(&schema(), &message), "Tail").unwrap(); - assert!(warnings.iter().any(|w| w.contains("stroke styles of single vector network segments were left out")), "{warnings:?}"); - assert_eq!(network_of(&doc, "Wave").segments.len(), 4); + assert!(!plain.iter().any(|w| w.contains("vector network segments")), "{plain:?}"); + let uninterpreted = |w: &String| w.contains("metadata for styled vector network segments was not interpreted"); + let missing = |w: &String| w.contains("vector network segments reference missing style entries"); + // Style 1 has a row in the layer's override table; its contents are not applied. + let known = styled_import(1); + assert_eq!(known.iter().filter(|w| uninterpreted(w)).count(), 1, "{known:?}"); + assert!(!known.iter().any(missing), "{known:?}"); + // Style 7 names no row at all. + let unknown = styled_import(7); + assert_eq!(unknown.iter().filter(|w| missing(w)).count(), 1, "{unknown:?}"); + assert!(!unknown.iter().any(uninterpreted), "{unknown:?}"); } #[test] @@ -385,6 +397,30 @@ fn damaged_network_blobs_never_panic() { } } +#[test] +fn segment_styles_round_trip_without_changing_geometry() { + let original = Network { + vertices: vec![vtx(0, 2.0, 3.0), vtx(0, 10.0, 20.0), vtx(0, 40.0, 50.0), vtx(0, 60.0, 70.0)], + segments: vec![ + seg(0, 1, (1.0, 2.0), (3.0, 4.0)), + Segment { style: 17, a: 1, ta: (5.0, 6.0), b: 2, tb: (7.0, 8.0) }, + Segment { style: 27, a: 2, ta: (9.0, 10.0), b: 3, tb: (11.0, 12.0) }, + ], + regions: vec![], + }; + let bytes = network::encode(&original); + let (decoded, styled) = network::decode_styled(&bytes).unwrap(); + assert_eq!(styled, 2); + assert_eq!(decoded.segments.len(), 3); + for (got, want) in decoded.segments.iter().zip(&original.segments) { + assert_eq!(got.style, want.style); + assert_eq!((got.a, got.ta, got.b, got.tb), (want.a, want.ta, want.b, want.tb)); + } + for cut in 0..bytes.len() { + assert!(network::decode_styled(&bytes[..cut]).is_none(), "truncated at {cut}"); + } +} + #[test] fn mangled_tail_files_never_panic() { let c = tail_canvas(); From 120b6830ff94385e6d8c3738673570199fceb466 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:36:24 +0200 Subject: [PATCH 015/118] M1: import editable text on a path from .fig files Supported TEXT_PATH layers import as editable native text on their vector path, including mixed text styles, a start inside a later segment and reversed text direction. The importer converts the segment parameter stored in the file to the native distance along the complete path. The mapping accepts a connected, unbranched path with one recognized start-segment marker and the default placement above the path. Damaged or unsupported path data keeps the text readable off its path and adds an import note. Other vertical placements, multiline text and ambiguous or disconnected networks are not converted. Underline and strikethrough remain a native path-rendering limitation and are reported. Public REST TEXT_PATH layers and the experimental .fig writer are unchanged. --- crates/format/src/fig/map.rs | 95 ++++++- crates/format/src/fig/mod.rs | 3 + crates/format/src/fig/text_path.rs | 315 +++++++++++++++++++++++ crates/format/src/fig/text_path_tests.rs | 211 +++++++++++++++ crates/format/src/rest/text.rs | 12 +- docs/gaps.md | 4 + 6 files changed, 637 insertions(+), 3 deletions(-) create mode 100644 crates/format/src/fig/text_path.rs create mode 100644 crates/format/src/fig/text_path_tests.rs diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 05fa8566..1dc8e138 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -759,7 +759,7 @@ impl<'a> Mapper<'a> { "VARIABLE" | "VARIABLE_SET" => return None, t => stand.map_or(t, |s| s.0), }; - if let Some((_, what)) = stand { + if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH") { self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " "))); } self.count += 1; @@ -774,7 +774,12 @@ impl<'a> Mapper<'a> { copy_str(&mut o, &view, "name", "name"); self.common(&mut o, &view, ty, space, parent); match ty { - "TEXT" => self.text(&mut o, &view), + "TEXT" => { + self.text(&mut o, &view); + if own == "TEXT_PATH" { + self.text_path(&mut o, &view); + } + } "VECTOR" | "STAR" | "REGULAR_POLYGON" => self.geometry(&mut o, &view), "ELLIPSE" => { if let Some(a) = view.get("arcData") { @@ -1155,6 +1160,92 @@ impl<'a> Mapper<'a> { } } + /// Carry supported text-path data through the private .fig REST ingress only. + fn text_path(&mut self, o: &mut Obj, v: &View<'a>) { + let fail = |this: &mut Self, reason: &str| { + this.warn(format!("text path {reason}; text imports off its path")); + }; + let Some(start) = v.get("textPathStart") else { + fail(self, "has no start data"); + return; + }; + let (Some(t), Some(forward)) = (num(start, "tValue"), start.get("forward").and_then(Kv::bool)) else { + fail(self, "has invalid start data"); + return; + }; + let Some(vd) = v.get("vectorData") else { + fail(self, "has no vector data"); + return; + }; + let Some(blob) = num(vd, "vectorNetworkBlob") + .filter(|i| i.is_finite() && *i >= 0.0 && i.fract() == 0.0) + .and_then(|i| usize::try_from(i as u64).ok()) + .and_then(|i| self.blobs.get(i)) + .copied() + else { + fail(self, "has no vector network"); + return; + }; + let Some((net, _)) = network::decode_styled(blob) else { + fail(self, "has damaged vector data"); + return; + }; + let Some(norm) = vd.get("normalizedSize") else { + fail(self, "has invalid normalized size"); + return; + }; + let Some(size) = v.get("size") else { + fail(self, "has invalid dimensions"); + return; + }; + let (Some(nx), Some(ny), Some(w), Some(h)) = (num(norm, "x"), num(norm, "y"), num(size, "x"), num(size, "y")) else { + fail(self, "has invalid dimensions"); + return; + }; + let axis_scale = |size: f64, normalized: f64, values: &[f64]| -> Option { + if !size.is_finite() || size < 0.0 || !normalized.is_finite() || normalized < 0.0 { + return None; + } + if normalized > 1e-9 { + if size <= 0.0 { + return None; + } + return Some(size / normalized); + } + (size == 0.0 && values.iter().all(|v| *v == 0.0)).then_some(1.0) + }; + let xs: Vec = net.vertices.iter().map(|p| p.x).chain(net.segments.iter().flat_map(|s| [s.ta.0, s.tb.0])).collect(); + let ys: Vec = net.vertices.iter().map(|p| p.y).chain(net.segments.iter().flat_map(|s| [s.ta.1, s.tb.1])).collect(); + let sx = axis_scale(w, nx, &xs); + let sy = axis_scale(h, ny, &ys); + let (Some(sx), Some(sy)) = (sx, sy) else { + fail(self, "has invalid path scale"); + return; + }; + if v.str("textAlignVertical").is_some_and(|x| x != "TOP") { + fail(self, "uses unsupported vertical placement"); + return; + } + if v.get("textData").and_then(|td| td.get("characters")).and_then(Kv::str).is_some_and(|s| s.chars().any(|c| matches!(c, '\n' | '\r'))) { + fail(self, "contains multiple lines"); + return; + } + let path = match super::text_path::from_network(&net, sx, sy, t, forward) { + Ok(path) => path, + Err(reason) => { + fail(self, reason); + return; + } + }; + let has_decoration = |style: &Value| style.get("textDecoration").and_then(Value::as_str).is_some_and(|x| x != "NONE"); + let decorated = o.get("style").is_some_and(has_decoration) + || o.get("styleOverrideTable").and_then(Value::as_object).into_iter().flat_map(|styles| styles.values()).any(has_decoration); + if decorated { + self.warn("text path underline or strikethrough decoration is not rendered"); + } + o.insert("_figTextPath".into(), json!({"d": path.d, "start": path.start, "flip": path.flip})); + } + /// `fillGeometry` / `strokeGeometry` as SVG path data, and star/polygon parameters. fn geometry(&mut self, o: &mut Obj, v: &View<'a>) { for key in ["fillGeometry", "strokeGeometry"] { diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 81457f8d..2ad6e276 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -24,6 +24,9 @@ mod network; mod source_identity_tests; #[cfg(test)] mod tests; +mod text_path; +#[cfg(test)] +mod text_path_tests; #[cfg(test)] mod text_style_tests; diff --git a/crates/format/src/fig/text_path.rs b/crates/format/src/fig/text_path.rs new file mode 100644 index 00000000..8a93f7e4 --- /dev/null +++ b/crates/format/src/fig/text_path.rs @@ -0,0 +1,315 @@ +//! Convert the simple vector network used by text-on-path layers in `.fig` files into a native text path. + +use figcraft_doc::{ + TextPath, + geom::{ + BezPath, + kurbo::{CubicBez, Line, ParamCurve, ParamCurveArclen, Point}, + }, +}; + +use super::network::{Network, Segment}; + +const START_SEGMENT_STYLE: u32 = 0x01ff_ffff; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Use { + segment: usize, + forward: bool, +} + +/// Build a native path from a validated connected chain or cycle. The marked source segment is +/// oriented a→b; `t` is local to that line/cubic. Coordinates are scaled into layer space. +pub(super) fn from_network(net: &Network, sx: f64, sy: f64, t: f64, forward: bool) -> Result { + if !t.is_finite() || !(0.0..=1.0).contains(&t) { + return Err("invalid start parameter"); + } + if !sx.is_finite() || !sy.is_finite() || sx <= 0.0 || sy <= 0.0 { + return Err("invalid path scale"); + } + let markers: Vec = net.segments.iter().enumerate().filter_map(|(i, s)| (s.style == START_SEGMENT_STYLE).then_some(i)).collect(); + let [marked] = markers.as_slice() else { + return Err("missing or ambiguous start segment marker"); + }; + + let (uses, closed) = ordered_chain(net, *marked)?; + let mut d = BezPath::new(); + let mut total = 0.0; + let mut prefix = 0.0; + let mut start_segment_length = 0.0; + for use_ in &uses { + let (p0, p1, p2, p3) = cubic(net, *use_, sx, sy)?; + if d.elements().is_empty() { + d.move_to((p0.0, p0.1)); + } + let line = p1 == p0 && p2 == p3; + let (length, partial) = if line { + d.line_to((p3.0, p3.1)); + let line = Line::new(Point::new(p0.0, p0.1), Point::new(p3.0, p3.1)); + (line.arclen(1e-9), line.arclen(1e-9) * t) + } else { + d.curve_to((p1.0, p1.1), (p2.0, p2.1), (p3.0, p3.1)); + let curve = CubicBez::new((p0.0, p0.1), (p1.0, p1.1), (p2.0, p2.1), (p3.0, p3.1)); + let partial = if t <= 0.0 { 0.0 } else { curve.subsegment(0.0..t).arclen(1e-9) }; + (curve.arclen(1e-9), partial) + }; + if !length.is_finite() || length <= 1e-10 { + return Err("zero-length path segment"); + } + if use_.segment == *marked { + start_segment_length = partial; + if !start_segment_length.is_finite() { + return Err("invalid path length"); + } + prefix = total; + } + total += length; + } + if closed { + d.close_path(); + } + if !total.is_finite() || total <= 1e-10 { + return Err("zero-length path"); + } + let mut start = ((prefix + start_segment_length) / total).clamp(0.0, 1.0); + let flip = !forward; + if flip { + start = 1.0 - start; + } + let path = TextPath { d: d.to_svg(), start, flip }; + let lines = figcraft_doc::geom::measure::polylines(&path.path()).ok_or("cannot be measured by text renderer")?; + if lines.first().is_none_or(|line| line.length() <= 1e-9) { + return Err("cannot be measured by text renderer"); + } + Ok(path) +} + +/// Return every segment in chain order. Open chains are oriented so the marked segment runs a→b; +/// cycles start at the marked segment's a vertex and traverse it first. +fn ordered_chain(net: &Network, marked: usize) -> Result<(Vec, bool), &'static str> { + let marker = net.segments.get(marked).ok_or("invalid start segment index")?; + let mut adjacency = vec![Vec::::new(); net.vertices.len()]; + for (i, s) in net.segments.iter().enumerate() { + if s.a >= adjacency.len() || s.b >= adjacency.len() { + return Err("invalid path vertex index"); + } + if s.a == s.b { + return Err("path contains a segment that starts and ends at the same vertex"); + } + adjacency[s.a].push(i); + adjacency[s.b].push(i); + } + let mut endpoints = Vec::new(); + for edges in &adjacency { + if edges.len() > 2 { + return Err("branched path network"); + } + if edges.len() == 1 { + endpoints.push(edges[0]); + } + } + if !endpoints.is_empty() && endpoints.len() != 2 { + return Err("disconnected or malformed path network"); + } + let cycle = endpoints.is_empty(); + let mut used = vec![false; net.segments.len()]; + let mut out = Vec::with_capacity(net.segments.len()); + if cycle { + out.push(Use { segment: marked, forward: true }); + used[marked] = true; + let mut at = marker.b; + let mut visited = vec![false; net.vertices.len()]; + visited[marker.a] = true; + visited[marker.b] = true; + while at != marker.a { + let next = adjacency[at].iter().copied().find(|i| !used[*i]).ok_or("disconnected path cycle")?; + used[next] = true; + let s = &net.segments[next]; + let is_forward = s.a == at; + at = if is_forward { s.b } else { s.a }; + if at != marker.a && std::mem::replace(&mut visited[at], true) { + return Err("path cycle is not simple"); + } + out.push(Use { segment: next, forward: is_forward }); + if out.len() > net.segments.len() { + return Err("invalid path cycle"); + } + } + } else { + let endpoint = adjacency.iter().position(|e| e.len() == 1).ok_or("missing path endpoint")?; + let mut at = endpoint; + while let Some(next) = adjacency[at].iter().copied().find(|i| !used[*i]) { + used[next] = true; + let s = &net.segments[next]; + let is_forward = s.a == at; + at = if is_forward { s.b } else { s.a }; + out.push(Use { segment: next, forward: is_forward }); + } + let Some(marker_use) = out.iter().find(|u| u.segment == marked) else { return Err("disconnected start segment") }; + if !marker_use.forward { + out.reverse(); + for u in &mut out { + u.forward = !u.forward; + } + } + } + if !used.iter().all(|x| *x) { + return Err("disconnected path network"); + } + Ok((out, cycle)) +} + +type P = (f64, f64); + +fn cubic(net: &Network, use_: Use, sx: f64, sy: f64) -> Result<(P, P, P, P), &'static str> { + let s: &Segment = net.segments.get(use_.segment).ok_or("invalid path segment index")?; + let a = net.vertices.get(s.a).ok_or("invalid path vertex index")?; + let b = net.vertices.get(s.b).ok_or("invalid path vertex index")?; + let p0 = (a.x * sx, a.y * sy); + let p3 = (b.x * sx, b.y * sy); + let ta = (s.ta.0 * sx, s.ta.1 * sy); + let tb = (s.tb.0 * sx, s.tb.1 * sy); + if [p0.0, p0.1, p3.0, p3.1, ta.0, ta.1, tb.0, tb.1].iter().any(|x| !x.is_finite()) { + return Err("invalid path coordinates"); + } + let (p0, p1, p2, p3) = + if ta == (0.0, 0.0) && tb == (0.0, 0.0) { (p0, p0, p3, p3) } else { (p0, (p0.0 + ta.0, p0.1 + ta.1), (p3.0 + tb.0, p3.1 + tb.1), p3) }; + if use_.forward { Ok((p0, p1, p2, p3)) } else { Ok((p3, p2, p1, p0)) } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fig::network::{Segment, Vertex}; + use figcraft_doc::geom::kurbo::PathEl; + + fn vertex(x: f64, y: f64) -> Vertex { + Vertex { style: 0, x, y } + } + + fn segment(style: u32, a: usize, b: usize, ta: (f64, f64), tb: (f64, f64)) -> Segment { + Segment { style, a, ta, b, tb } + } + + fn curved() -> Network { + Network { + vertices: vec![vertex(0.0, 82.5), vertex(300.0, 82.5), vertex(600.0, 82.5)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (70.0, -110.0), (-70.0, -110.0)), segment(0, 1, 2, (70.0, 110.0), (-70.0, 110.0))], + ..Network::default() + } + } + + #[test] + fn local_cubic_parameter_converts_to_path_distance() { + let path = from_network(&curved(), 1.0, 1.0, 0.0086954795, true).unwrap(); + assert!((path.start - 0.004744078).abs() < 0.00001); + // Cubic t is not its fraction of total path length. + assert!((path.start - 0.0086954795).abs() > 0.003); + let elements = path.path().elements().to_vec(); + assert!(matches!(elements.first(), Some(PathEl::MoveTo(p)) if p.x == 0.0 && p.y == 82.5)); + assert!(matches!(elements.get(1), Some(PathEl::CurveTo(..)))); + } + + #[test] + fn second_segment_marker_and_t_are_respected() { + let mut net = curved(); + net.segments[0].style = 0; + net.segments[1].style = START_SEGMENT_STYLE; + let path = from_network(&net, 1.0, 1.0, 0.49800667, true).unwrap(); + assert!((path.start - 0.7490379757).abs() < 0.00002); + } + + #[test] + fn reverse_direction_inverts_normalized_start() { + let net = Network { + vertices: vec![vertex(0.0, 0.0), vertex(600.0, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + let path = from_network(&net, 1.0, 1.0, 0.83986014, false).unwrap(); + assert!(path.flip); + assert!((path.start - 0.16013986).abs() < 1e-8); + assert!(matches!(path.path().elements().get(1), Some(PathEl::LineTo(p)) if p.x == 600.0 && p.y == 0.0)); + } + + #[test] + fn rejects_missing_duplicate_and_zero_length_segments() { + let mut net = curved(); + net.segments[0].style = 0; + assert_eq!(from_network(&net, 1.0, 1.0, 0.5, true), Err("missing or ambiguous start segment marker")); + net.segments[1].style = START_SEGMENT_STYLE; + net.segments[0].style = START_SEGMENT_STYLE; + assert_eq!(from_network(&net, 1.0, 1.0, 0.5, true), Err("missing or ambiguous start segment marker")); + let zero = Network { + vertices: vec![vertex(0.0, 0.0), vertex(0.0, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + assert_eq!(from_network(&zero, 1.0, 1.0, 0.5, true), Err("zero-length path segment")); + } + + #[test] + fn rejects_branching_and_disconnected_segments() { + let branch = Network { + vertices: vec![vertex(0.0, 0.0), vertex(1.0, 0.0), vertex(2.0, 0.0), vertex(1.0, 1.0)], + segments: vec![ + segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)), + segment(0, 1, 2, (0.0, 0.0), (0.0, 0.0)), + segment(0, 1, 3, (0.0, 0.0), (0.0, 0.0)), + ], + ..Network::default() + }; + assert_eq!(from_network(&branch, 1.0, 1.0, 0.5, true), Err("branched path network")); + let disconnected = Network { + vertices: vec![vertex(0.0, 0.0), vertex(1.0, 0.0), vertex(2.0, 0.0), vertex(3.0, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)), segment(0, 2, 3, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + assert_eq!(from_network(&disconnected, 1.0, 1.0, 0.5, true), Err("disconnected or malformed path network")); + } + + #[test] + fn scales_geometry_and_accepts_closed_cycles() { + let cycle = Network { + vertices: vec![vertex(0.0, 0.0), vertex(100.0, 0.0), vertex(100.0, 100.0)], + segments: vec![ + segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)), + segment(0, 1, 2, (0.0, 0.0), (0.0, 0.0)), + segment(0, 2, 0, (0.0, 0.0), (0.0, 0.0)), + ], + ..Network::default() + }; + let path = from_network(&cycle, 2.0, 3.0, 0.5, true).unwrap(); + let elements = path.path().elements().to_vec(); + assert!(matches!(elements.first(), Some(PathEl::MoveTo(p)) if p.x == 0.0 && p.y == 0.0)); + assert!(matches!(elements.get(1), Some(PathEl::LineTo(p)) if p.x == 200.0 && p.y == 0.0)); + let total = 200.0 + 300.0 + 130_000.0_f64.sqrt(); + assert!((path.start - 100.0 / total).abs() < 1e-8); + assert!(matches!(elements.last(), Some(PathEl::ClosePath))); + } + + #[test] + fn rejects_paths_the_native_renderer_cannot_measure() { + let count = 40_000usize; + let many_lines = Network { + vertices: (0..=count).map(|i| vertex(i as f64, 0.0)).collect(), + segments: (0..count).map(|i| segment(if i == 0 { START_SEGMENT_STYLE } else { 0 }, i, i + 1, (0.0, 0.0), (0.0, 0.0))).collect(), + ..Network::default() + }; + assert_eq!(from_network(&many_lines, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer")); + + let huge = Network { + vertices: vec![vertex(0.0, 0.0), vertex(1e9, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + assert_eq!(from_network(&huge, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer")); + + let tiny = Network { + vertices: vec![vertex(0.0, 0.0), vertex(5e-10, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + assert_eq!(from_network(&tiny, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer")); + } +} diff --git a/crates/format/src/fig/text_path_tests.rs b/crates/format/src/fig/text_path_tests.rs new file mode 100644 index 00000000..66148930 --- /dev/null +++ b/crates/format/src/fig/text_path_tests.rs @@ -0,0 +1,211 @@ +//! Mapper → private REST ingress tests for source text-path fields. + +use super::kiwi::Kv; + +const SCHEMA: &str = " +enum NodeType DOCUMENT CANVAS TEXT TEXT_PATH +enum StyleType NONE TEXT +enum TextDecoration NONE UNDERLINE STRIKETHROUGH +struct GUID uint sessionID, uint localID +struct StyleId GUID guid +struct ParentIndex GUID guid, string position +struct Vector float x, float y +struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 +struct FontName string family, string style +message TextOverride uint styleID, FontName fontName, float fontSize +message TextData string characters, uint[] characterStyleIDs, TextOverride[] styleOverrideTable +message TextPathStart float tValue, bool forward +message VectorData uint vectorNetworkBlob, Vector normalizedSize +message Blob byte[] bytes +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, TextData textData, float fontSize, FontName fontName, TextPathStart textPathStart, VectorData vectorData, StyleType styleType, StyleId styleIdForText, TextDecoration textDecoration +message Message NodeChange[] nodeChanges, Blob[] blobs +"; +use figcraft_doc::{NodeKind, TextPath}; + +fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} + +fn a(values: Vec>) -> Kv<'static> { + Kv::Array(values) +} + +fn f(value: f32) -> Kv<'static> { + Kv::Float(value) +} + +fn u(value: u64) -> Kv<'static> { + Kv::Uint(value) +} + +fn s(value: &'static str) -> Kv<'static> { + Kv::Str(value.into()) +} + +fn point(x: f32, y: f32) -> Kv<'static> { + o(vec![("x", f(x)), ("y", f(y))]) +} + +fn guid(id: u64) -> Kv<'static> { + o(vec![("sessionID", u(1)), ("localID", u(id))]) +} + +fn node(id: u64, parent: Option, ty: &'static str, fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut node = vec![("guid", guid(id)), ("type", s(ty))]; + if let Some(parent) = parent { + node.push(("parentIndex", o(vec![("guid", guid(parent)), ("position", s("!"))]))); + } + node.extend(fields); + o(node) +} + +fn blob() -> Vec { + let mut bytes = Vec::new(); + let u32le = |out: &mut Vec, n: u32| out.extend_from_slice(&n.to_le_bytes()); + let f32le = |out: &mut Vec, n: f32| out.extend_from_slice(&n.to_le_bytes()); + u32le(&mut bytes, 2); // vertices + u32le(&mut bytes, 1); // segments + u32le(&mut bytes, 0); // regions + for (x, y) in [(0.0, 0.0), (100.0, 0.0)] { + u32le(&mut bytes, 0); + f32le(&mut bytes, x); + f32le(&mut bytes, y); + } + u32le(&mut bytes, 0x01ff_ffff); // source start-segment marker + u32le(&mut bytes, 0); + f32le(&mut bytes, 0.0); + f32le(&mut bytes, 0.0); + u32le(&mut bytes, 1); + f32le(&mut bytes, 0.0); + f32le(&mut bytes, 0.0); + bytes +} + +fn canvas() -> Vec { + let schema = super::kiwi::Schema::parse(SCHEMA).unwrap(); + let message = message(true); + let message = super::kiwi::encode::message(&schema, schema.find("Message").unwrap(), &message).unwrap(); + let mut out = b"fig-kiwi\0\0\0\0".to_vec(); + for raw in [super::kiwi::encode::schema(&schema), message] { + let mut encoder = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut encoder, &raw).unwrap(); + let compressed = encoder.finish().unwrap(); + out.extend_from_slice(&(compressed.len() as u32).to_le_bytes()); + out.extend_from_slice(&compressed); + } + out +} + +fn message(with_path: bool) -> Kv<'static> { + let mut nodes = vec![node(0, None, "DOCUMENT", vec![]), node(1, Some(0), "CANVAS", vec![])]; + let mut blobs = Vec::new(); + if with_path { + blobs.push(o(vec![("bytes", Kv::Bytes(blob()))])); + let text = o(vec![ + ("characters", s("alpha")), + ("characterStyleIDs", a(vec![u(0), u(0), u(0), u(2), u(2)])), + ( + "styleOverrideTable", + a(vec![o(vec![("styleID", u(2)), ("fontName", o(vec![("family", s("Inter")), ("style", s("Medium"))])), ("fontSize", f(30.0))])]), + ), + ]); + nodes.push(node( + 2, + Some(1), + "TEXT_PATH", + vec![ + ("name", s("alpha path")), + ("size", point(100.0, 0.0)), + ("transform", o(vec![("m00", f(1.0)), ("m01", f(0.0)), ("m02", f(0.0)), ("m10", f(0.0)), ("m11", f(1.0)), ("m12", f(0.0))])), + ("textData", text), + ("fontSize", f(24.0)), + ("fontName", o(vec![("family", s("Inter")), ("style", s("Regular"))])), + ("textPathStart", o(vec![("tValue", f(0.5)), ("forward", Kv::Bool(true))])), + ("vectorData", o(vec![("vectorNetworkBlob", u(0)), ("normalizedSize", point(100.0, 0.0))])), + ], + )); + } else { + nodes.push(node(2, Some(1), "TEXT_PATH", vec![("name", s("broken path")), ("textData", o(vec![("characters", s("fallback"))]))])); + } + o(vec![("nodeChanges", a(nodes)), ("blobs", a(blobs))]) +} + +fn linked_underline_message() -> Kv<'static> { + let Kv::Object(mut root) = message(true) else { panic!("expected message object") }; + let (_, Kv::Array(nodes)) = root.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap() else { panic!("expected node changes array") }; + let text = nodes.iter_mut().find(|n| n.get("name").and_then(Kv::str) == Some("alpha path")).unwrap(); + let Kv::Object(fields) = text else { panic!("expected text node object") }; + fields.push(("styleIdForText", o(vec![("guid", guid(3))]))); + nodes.push(node( + 3, + Some(1), + "TEXT", + vec![ + ("name", s("Shared style")), + ("styleType", s("TEXT")), + ("fontName", o(vec![("family", s("Inter")), ("style", s("Regular"))])), + ("fontSize", f(24.0)), + ("textDecoration", s("UNDERLINE")), + ], + )); + Kv::Object(root) +} + +#[test] +fn mapped_text_path_reaches_native_model_with_runs_and_no_standin_warning() { + let message = message(true); + let (json, warnings) = super::map::to_rest(&message, "Text path"); + assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}"); + let (doc, warnings) = crate::rest::import_fig(&json, &|_| None).unwrap(); + assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}"); + let node = doc.nodes().find(|n| n.name == "alpha path").unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("expected text") }; + let path = text.path.as_deref().unwrap(); + assert_eq!(path, &TextPath { d: "M0,0 L100,0".into(), start: 0.5, flip: false }); + assert_eq!(text.characters, "alpha"); + assert_eq!(text.runs.len(), 1); + assert_eq!(text.runs[0].start, 3); + assert_eq!(text.runs[0].end, 5); +} + +#[test] +fn public_rest_import_ignores_private_fig_text_path_field() { + let message = message(true); + let (json, _) = super::map::to_rest(&message, "Text path"); + // The mapper's private field is accepted only by import_fig, never by public REST import. + let path = json["document"]["children"][0]["children"][0]["_figTextPath"].clone(); + assert!(path.is_object()); + let (doc, _) = crate::rest::import(&json, &|_| None).unwrap(); + let node = doc.nodes().find(|n| n.name == "alpha path").unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("expected text") }; + assert!(text.path.is_none()); +} + +#[test] +fn serialized_kiwi_canvas_import_decodes_start_and_builds_native_path() { + let (doc, warnings) = super::import(&canvas(), "Serialized text path").unwrap(); + let node = doc.nodes().find(|n| n.name == "alpha path").unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("expected text") }; + let path = text.path.as_deref().unwrap(); + assert_eq!(path, &TextPath { d: "M0,0 L100,0".into(), start: 0.5, flip: false }); + assert_eq!(text.characters, "alpha"); + assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}"); +} + +#[test] +fn malformed_text_path_falls_back_with_specific_warning() { + let message = message(false); + let (json, warnings) = super::map::to_rest(&message, "Text path"); + assert!(warnings.iter().any(|w| w.contains("text path has no start data") && w.contains("text imports off its path")), "{warnings:?}"); + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + let node = doc.nodes().find(|n| n.name == "broken path").unwrap(); + assert!(matches!(&node.kind, NodeKind::Text(text) if text.path.is_none())); +} + +#[test] +fn linked_text_style_decoration_warns_from_effective_mapped_style() { + let message = linked_underline_message(); + let (json, warnings) = super::map::to_rest(&message, "Text path"); + assert_eq!(json["document"]["children"][0]["children"][0]["style"]["textDecoration"], "UNDERLINE"); + assert!(warnings.iter().any(|w| w == "text path underline or strikethrough decoration is not rendered"), "{warnings:?}"); +} diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index 3875204f..9b8e9be4 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; use figcraft_doc::{ DecorationLength, DecorationStyle, LetterSpacing, LineHeight, ListStyle, ParaList, StyleRun, TextAlignH, TextAlignV, TextAutoResize, TextCase, - TextData, TextDecoration, TextStyle, + TextData, TextDecoration, TextPath, TextStyle, }; use serde_json::Value; @@ -29,6 +29,16 @@ pub(super) fn text_data(cx: &mut Ctx, v: &Value) -> TextData { td.runs = runs(cx, v, &td); td.normalize(); lists(v, &mut td); + if cx.fig_sources.is_some() + && let Some(path) = v.get("_figTextPath") + { + match (path.get("d").and_then(Value::as_str), path.get("start").and_then(Value::as_f64), path.get("flip").and_then(Value::as_bool)) { + (Some(d), Some(start), Some(flip)) if !d.is_empty() && start.is_finite() && (0.0..=1.0).contains(&start) => { + td.path = Some(Box::new(TextPath { d: d.to_owned(), start, flip })); + } + _ => cx.warn("invalid internal .fig text path data was ignored"), + } + } td } diff --git a/docs/gaps.md b/docs/gaps.md index e8b111a8..a5cd6a3b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -41,6 +41,10 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe ## Entries ### 1. `.fig` files: the import's tail +- **Text paths (2026-10-15):** local `.fig` import maps supported connected `TEXT_PATH` networks + to existing editable native text paths, preserving the start segment, direction and styled + runs. Other vertical placements, multiline/ambiguous networks, path decorations, public REST + text paths and `.fig` text-path export remain limited. - **Landed (2026-10-11):** `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors, auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning (`crates/format/src/fig`). Then (2026-10-11): vectors open as editable vector networks (per-point corner radius, handle mirroring, end caps; outlines when the network blob is missing or damaged), sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning, and set-variable, set-variable-mode and conditional prototype actions come across. `file.open`, `file.import`, drag and drop, the CLI and MCP's `open_file` read `.fig`; the file is never saved over. From 3723fcffbd84faff68912a68426b64669a44aae8 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:42:33 +0200 Subject: [PATCH 016/118] M1: keep text decoration skip ink from .fig files Text layers, styled runs and text styles in .fig files carry a skip-ink boolean for their decoration. The importer now reads it into the native decoration look: an omitted run value inherits the layer's, a local value overrides a linked shared text style without changing the style itself, and instance overrides keep an explicit false over a true main. A malformed value counts as unset. The exporter writes the actual value of the layer and of each styled run instead of a constant true, and a decoration look that differs from the default only in skip ink no longer triggers the unsupported-decoration note, so default native text exports its real false value. --- crates/format/src/fig/export.rs | 10 +- crates/format/src/fig/interop_tests.rs | 3 +- crates/format/src/fig/map.rs | 6 + crates/format/src/fig/mod.rs | 2 + crates/format/src/fig/text_skip_ink_tests.rs | 237 +++++++++++++++++++ 5 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 crates/format/src/fig/text_skip_ink_tests.rs diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index bfd0b615..dd3f2e88 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1502,7 +1502,6 @@ impl<'d> Exporter<'d> { ("textExplicitLayoutVersion", u(1)), ("textBidiVersion", u(1)), ("textTracking", fl(0.0)), - ("textDecorationSkipInk", b(true)), ("emojiImageSet", e("APPLE")), ]); if td.auto_resize == TextAutoResize::Truncate { @@ -1589,7 +1588,7 @@ impl<'d> Exporter<'d> { self.warn("nested list levels were written as the first level"); } let styles = std::iter::once(&td.style).chain(td.runs.iter().map(|r| &r.style)); - if styles.clone().any(|st| st.decoration_look.is_some() || !st.variations.is_empty()) { + if styles.clone().any(|st| unsupported_decoration_look(st) || !st.variations.is_empty()) { self.warn("text decoration looks and variable font settings were left out"); } if td.path.is_some() { @@ -2394,6 +2393,12 @@ fn key_codes(key: &str) -> Option<(Vec, bool)> { } /// The text style fields of a layer or of an override table entry. +fn unsupported_decoration_look(st: &TextStyle) -> bool { + let mut look = st.look(); + look.skip_ink = false; + look != Default::default() +} + fn style_fields(st: &TextStyle, f: &mut Fields) { let line_height = match st.line_height { // Percent units count from the font's own line height (100 % is "auto"); a share of the @@ -2425,6 +2430,7 @@ fn style_fields(st: &TextStyle, f: &mut Fields) { ("letterSpacing", letter_spacing), ("textCase", e(case)), ("textDecoration", e(decoration)), + ("textDecorationSkipInk", b(st.look().skip_ink)), ]); if let Some(url) = &st.link { f.push(("hyperlink", o(vec![("url", s(url))]))); diff --git a/crates/format/src/fig/interop_tests.rs b/crates/format/src/fig/interop_tests.rs index b51760c5..56391975 100644 --- a/crates/format/src/fig/interop_tests.rs +++ b/crates/format/src/fig/interop_tests.rs @@ -187,7 +187,8 @@ fn text_carries_its_layout_settings_and_a_line_per_paragraph() { with_changes(&sample(), |of, _| { let t = of("TEXT"); assert!(!flag(&t, "autoRename")); - assert!(flag(&t, "fontVariantCommonLigatures") && flag(&t, "fontVariantContextualLigatures") && flag(&t, "textDecorationSkipInk")); + assert!(flag(&t, "fontVariantCommonLigatures") && flag(&t, "fontVariantContextualLigatures")); + assert!(!flag(&t, "textDecorationSkipInk"), "default native text exports its actual false value"); assert_eq!(name(&t, "fontVersion"), ""); assert_eq!((num(&t, "textUserLayoutVersion"), num(&t, "textExplicitLayoutVersion"), num(&t, "textBidiVersion")), (5.0, 1.0, 1.0)); assert_eq!(num(&t, "textTracking"), 0.0); diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 0fe44662..745b6d81 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1119,6 +1119,9 @@ impl<'a> Mapper<'a> { if let Some(decoration @ ("NONE" | "UNDERLINE" | "STRIKETHROUGH")) = v.str("textDecoration") { style.insert("textDecoration".into(), json!(decoration)); } + if let Some(skip_ink) = v.flag("textDecorationSkipInk") { + style.insert("textDecorationSkipInk".into(), json!(skip_ink)); + } if let Some(case @ ("ORIGINAL" | "UPPER" | "LOWER" | "TITLE" | "SMALL_CAPS" | "SMALL_CAPS_FORCED")) = v.str("textCase") { style.insert("textCase".into(), json!(case)); } @@ -2096,6 +2099,9 @@ fn text_style(v: &View, size: Option) -> Obj { copy_num(&mut s, v, "paragraphIndent", "paragraphIndent"); copy_str(&mut s, v, "textCase", "textCase"); copy_str(&mut s, v, "textDecoration", "textDecoration"); + if let Some(skip_ink) = v.flag("textDecorationSkipInk") { + s.insert("textDecorationSkipInk".into(), json!(skip_ink)); + } let font_size = v.num("fontSize").or(size).unwrap_or(12.0); if let Some(ls) = v.get("letterSpacing") { let val = num(ls, "value").unwrap_or(0.0); diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 81457f8d..8b3f02a0 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -25,6 +25,8 @@ mod source_identity_tests; #[cfg(test)] mod tests; #[cfg(test)] +mod text_skip_ink_tests; +#[cfg(test)] mod text_style_tests; use std::io::Read; diff --git a/crates/format/src/fig/text_skip_ink_tests.rs b/crates/format/src/fig/text_skip_ink_tests.rs new file mode 100644 index 00000000..8d1cd295 --- /dev/null +++ b/crates/format/src/fig/text_skip_ink_tests.rs @@ -0,0 +1,237 @@ +//! `.fig` text skip-ink values map, inherit and round-trip as native decoration look state. + +use super::kiwi::Kv; +use super::{export, import}; +use figcraft_doc::{Document, NodeKind, StyleKind, StyleRun, TextData, TextStyle}; +use serde_json::Value; + +fn object(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} + +fn guid(id: u64) -> Kv<'static> { + object(vec![("sessionID", Kv::Uint(8)), ("localID", Kv::Uint(id))]) +} + +fn node(id: u64, ty: &'static str, name: &str, parent: Option, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut out = vec![("guid", guid(id)), ("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))]; + if let Some(parent) = parent { + out.push(("parentIndex", object(vec![("guid", guid(parent)), ("position", Kv::Str("a".into()))]))); + } + out.append(&mut fields); + object(out) +} + +fn font() -> Kv<'static> { + object(vec![("family", Kv::Str("Inter".into())), ("style", Kv::Str("Regular".into())), ("postscript", Kv::Str(String::new()))]) +} + +fn raw(base: Option>, runs: Vec>, chars: &str, ids: Vec, shared: Option>>) -> Value { + let mut layer = vec![ + ("fontName", font()), + ("fontSize", Kv::Float(20.0)), + ( + "textData", + object(vec![ + ("characters", Kv::Str(chars.into())), + ("characterStyleIDs", Kv::Array(ids.into_iter().map(Kv::Uint).collect())), + ("styleOverrideTable", Kv::Array(runs)), + ]), + ), + ]; + if let Some(value) = base { + layer.push(("textDecorationSkipInk", value)); + } + let mut nodes = vec![node(1, "DOCUMENT", "Document", None, vec![]), node(2, "CANVAS", "Page", Some(1), vec![])]; + if let Some(shared) = shared { + let mut fields = vec![("styleType", Kv::Enum("TEXT")), ("fontName", font()), ("fontSize", Kv::Float(20.0))]; + if let Some(value) = shared { + fields.push(("textDecorationSkipInk", value)); + } + nodes.push(node(10, "TEXT", "Body", Some(2), fields)); + layer.push(("styleIdForText", object(vec![("guid", guid(10))]))); + } + nodes.push(node(11, "TEXT", "Label", Some(2), layer)); + let message = object(vec![("nodeChanges", Kv::Array(nodes))]); + super::map::to_rest(&message, "Synthetic skip ink").0 +} + +fn imported(value: &Value) -> Document { + let (doc, warnings) = crate::rest::import_fig(value, &|_| None).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + doc +} + +fn label(doc: &Document) -> &TextData { + doc.nodes().find(|n| n.name == "Label").unwrap().kind.text().unwrap() +} + +fn skip_at(text: &TextData, index: usize) -> bool { + text.runs.iter().find(|r| r.start <= index && index < r.end).map_or_else(|| text.style.look().skip_ink, |r| r.style.look().skip_ink) +} + +#[test] +fn node_boolean_false_true_omitted_and_malformed_keep_typed_semantics() { + for (raw_value, expected) in [(Some(Kv::Bool(true)), true), (Some(Kv::Bool(false)), false), (None, false), (Some(Kv::Str("true".into())), false)] + { + let doc = imported(&raw(raw_value.clone(), vec![], "A", vec![0], None)); + assert_eq!(label(&doc).style.look().skip_ink, expected, "source value {raw_value:?}"); + } +} + +#[test] +fn runs_inherit_explicit_false_and_true_by_utf16_character_ids() { + let table = vec![ + object(vec![("styleID", Kv::Uint(1))]), + object(vec![("styleID", Kv::Uint(2)), ("textDecorationSkipInk", Kv::Bool(false))]), + object(vec![("styleID", Kv::Uint(3)), ("textDecorationSkipInk", Kv::Bool(true))]), + ]; + let doc = imported(&raw(Some(Kv::Bool(true)), table, "A😀BC", vec![0, 1, 1, 2, 3], None)); + let text = label(&doc); + assert_eq!(text.characters.chars().count(), 4); + assert_eq!(text.characters.encode_utf16().count(), 5, "the emoji occupies two source style-ID units"); + assert_eq!((0..text.char_count()).map(|i| skip_at(text, i)).collect::>(), vec![true, true, false, true]); + + let false_base = imported(&raw( + Some(Kv::Bool(false)), + vec![object(vec![("styleID", Kv::Uint(1)), ("textDecorationSkipInk", Kv::Bool(true))])], + "ab", + vec![1, 0], + None, + )); + assert_eq!((0..2).map(|i| skip_at(label(&false_base), i)).collect::>(), vec![true, false]); +} + +#[test] +fn local_skip_ink_override_preserves_shared_style_and_omitted_value_inherits() { + for (local, expected) in [(Some(false), false), (None, true)] { + let value = raw(local.map(Kv::Bool), vec![], "ab", vec![0, 0], Some(Some(Kv::Bool(true)))); + let doc = imported(&value); + let node = doc.nodes().find(|n| n.name == "Label").unwrap(); + assert_eq!(node.kind.text().unwrap().style.look().skip_ink, expected); + let linked = doc.style(node.styles.text.as_deref().unwrap()).unwrap(); + let StyleKind::Text { style } = &linked.kind else { panic!("linked definition must remain text") }; + assert!(style.look().skip_ink, "local value must not alter definition"); + } +} + +#[test] +fn instance_path_override_preserves_explicit_false_over_true_main() { + let main_text = node( + 21, + "TEXT", + "Label", + Some(20), + vec![ + ("fontName", font()), + ("fontSize", Kv::Float(20.0)), + ("textDecorationSkipInk", Kv::Bool(true)), + ("textData", object(vec![("characters", Kv::Str("A".into()))])), + ], + ); + let instance = node( + 30, + "INSTANCE", + "Copy", + Some(2), + vec![( + "symbolData", + object(vec![ + ("symbolID", guid(20)), + ( + "symbolOverrides", + Kv::Array(vec![object(vec![ + ("guidPath", object(vec![("guids", Kv::Array(vec![guid(21)]))])), + ("textDecorationSkipInk", Kv::Bool(false)), + ])]), + ), + ]), + )], + ); + let message = object(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, "DOCUMENT", "Document", None, vec![]), + node(2, "CANVAS", "Page", Some(1), vec![]), + node(20, "SYMBOL", "Main", Some(2), vec![]), + main_text, + instance, + ]), + )]); + let value = super::map::to_rest(&message, "Instance skip ink").0; + let copy = value["document"]["children"][0]["children"].as_array().unwrap().iter().find(|n| n["name"] == "Copy").unwrap(); + let label = copy["children"].as_array().unwrap().iter().find(|n| n["name"] == "Label").unwrap(); + assert_eq!(label["style"]["textDecorationSkipInk"], false); + let doc = imported(&value); + let copy = doc.nodes().find(|n| n.name == "Copy").unwrap(); + let instance_label = doc.node(*copy.children.first().unwrap()).unwrap(); + assert!(!instance_label.kind.text().unwrap().style.look().skip_ink); +} + +#[test] +fn export_writes_one_actual_node_value_and_complete_run_values_without_skip_only_warning() { + let mut doc = Document::new("Skip ink export"); + let mut text = TextData::new("abcd"); + text.style.set_look(|look| look.skip_ink = false); + let mut skipped = text.style.clone(); + skipped.set_look(|look| look.skip_ink = true); + text.runs = vec![StyleRun { start: 1, end: 3, style: skipped }]; + doc.create(doc.first_page().unwrap(), NodeKind::Text(text), "Label", 0.0, 0.0, 100.0, 30.0).unwrap(); + + let (bytes, warnings) = export(&doc, None).unwrap(); + assert!(warnings.is_empty(), "skip-ink is supported: {warnings:?}"); + let archive = super::Archive::open(&bytes).unwrap(); + let (schema, body) = super::decode_canvas(&archive.canvas).unwrap(); + let message = schema.decode_message(schema.find("Message").unwrap(), &body).unwrap(); + let value = super::debug_json(&message); + let layer = value["nodeChanges"].as_array().unwrap().iter().find(|n| n["name"] == "Label").unwrap(); + assert_eq!(layer["textDecorationSkipInk"], false); + assert_eq!(layer.as_object().unwrap().keys().filter(|k| *k == "textDecorationSkipInk").count(), 1); + let rows = layer["textData"]["styleOverrideTable"].as_array().unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["textDecorationSkipInk"], true); + let (back, warnings) = import(&bytes, "Skip ink export").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!((0..4).map(|i| skip_at(label(&back), i)).collect::>(), vec![false, true, true, false]); + + let mut custom = TextData::new("x"); + custom.style.set_look(|look| look.skip_ink = true); + custom.style.set_look(|look| look.thickness = figcraft_doc::DecorationLength::Pixels(2.0)); + let mut custom_doc = Document::new("Unsupported decoration"); + custom_doc.create(custom_doc.first_page().unwrap(), NodeKind::Text(custom), "Custom", 0.0, 0.0, 100.0, 30.0).unwrap(); + let (_, warnings) = export(&custom_doc, None).unwrap(); + assert!(warnings.iter().any(|w| w.contains("text decoration looks")), "custom look warning missing: {warnings:?}"); + + let mut variable = TextData::new("x"); + variable.style.variations.insert("wght".into(), 400.0); + let mut variable_doc = Document::new("Variable font warning"); + variable_doc.create(variable_doc.first_page().unwrap(), NodeKind::Text(variable), "Variable", 0.0, 0.0, 100.0, 30.0).unwrap(); + let (_, warnings) = export(&variable_doc, None).unwrap(); + assert!(warnings.iter().any(|w| w.contains("variable font settings")), "variation warning missing: {warnings:?}"); + + let mut shared = TextStyle::default(); + shared.set_look(|look| look.skip_ink = true); + let mut local = TextStyle::default(); + local.set_look(|look| look.skip_ink = false); + let mut linked_doc = Document::new("Shared skip ink"); + linked_doc.styles.push(figcraft_doc::Style { + id: "body".into(), + name: "Body".into(), + description: String::new(), + kind: StyleKind::Text { style: shared }, + bound: Default::default(), + }); + let id = linked_doc + .create(linked_doc.first_page().unwrap(), NodeKind::Text(TextData { style: local, ..TextData::new("x") }), "Label", 0.0, 0.0, 100.0, 30.0) + .unwrap(); + linked_doc.node_mut(id).unwrap().styles.text = Some("body".into()); + let (bytes, warnings) = export(&linked_doc, None).unwrap(); + assert!(warnings.is_empty(), "skip-ink-only shared styles are supported: {warnings:?}"); + let (back, warnings) = import(&bytes, "Shared skip ink").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let node = back.nodes().find(|n| n.name == "Label").unwrap(); + assert!(!node.kind.text().unwrap().style.look().skip_ink); + let linked = back.style(node.styles.text.as_deref().unwrap()).unwrap(); + let StyleKind::Text { style } = &linked.kind else { panic!("shared style link must remain text") }; + assert!(style.look().skip_ink, "local false must not alter the shared definition"); +} From 7fd44641f2190a564989062583e3743aaacc2acd Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:45:29 +0200 Subject: [PATCH 017/118] M1: keep paragraph options from .fig files Text layers, text styles and instance overrides in .fig files can carry list spacing, hanging list, hanging punctuation and leading trim. The importer now reads them from the highest layer with a valid value, so an invalid instance override row falls through to the valid value below it, while explicit zero, false and NONE values stay resets. Both text layers and text styles share one mapping. The native text data stores these options once per text node, so differing values on separate runs are not represented, and the experimental .fig exporter keeps its existing note that these settings are left out. --- crates/format/src/fig/map.rs | 37 +++ crates/format/src/fig/mod.rs | 2 + crates/format/src/fig/text_paragraph_tests.rs | 288 ++++++++++++++++++ 3 files changed, 327 insertions(+) create mode 100644 crates/format/src/fig/text_paragraph_tests.rs diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 745b6d81..f5306acc 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -140,6 +140,41 @@ fn copy_str(o: &mut Obj, n: &View, from: &str, to: &str) { } } +/// The value from the last override layer holding a valid one for an optional text setting; invalid instance rows fall through. +fn text_num(v: &View, key: &str) -> Option { + v.layers.iter().rev().find_map(|layer| { + layer.get(key).and_then(|value| match value { + Kv::Bool(_) => None, + _ => value.f64(), + }) + }) +} + +fn text_flag(v: &View, key: &str) -> Option { + v.layers.iter().rev().find_map(|layer| layer.get(key).and_then(Kv::bool)) +} + +fn insert_text_options(style: &mut Obj, v: &View) { + if let Some(value) = text_num(v, "listSpacing") { + style.insert("listSpacing".into(), json!(value)); + } + for k in ["hangingList", "hangingPunctuation"] { + if let Some(value) = text_flag(v, k) { + style.insert(k.into(), json!(value)); + } + } + if let Some(value) = leading_trim(v) { + style.insert("leadingTrim".into(), json!(value)); + } +} + +fn leading_trim<'a>(v: &View<'a>) -> Option<&'a str> { + v.layers.iter().rev().find_map(|layer| match layer.get("leadingTrim").and_then(Kv::str) { + Some(value @ ("NONE" | "CAP_HEIGHT")) => Some(value), + _ => None, + }) +} + /// A node seen through override layers: the last layer with a field wins. #[derive(Clone)] struct View<'a> { @@ -1132,6 +1167,7 @@ impl<'a> Mapper<'a> { for k in ["paragraphSpacing", "paragraphIndent"] { copy_num(&mut style, v, k, k); } + insert_text_options(&mut style, v); if let Some(n) = v.num("maxLines").filter(|n| *n >= 1.0) { style.insert("maxLines".into(), json!(n)); } @@ -2097,6 +2133,7 @@ fn text_style(v: &View, size: Option) -> Obj { copy_num(&mut s, v, "fontSize", "fontSize"); copy_num(&mut s, v, "paragraphSpacing", "paragraphSpacing"); copy_num(&mut s, v, "paragraphIndent", "paragraphIndent"); + insert_text_options(&mut s, v); copy_str(&mut s, v, "textCase", "textCase"); copy_str(&mut s, v, "textDecoration", "textDecoration"); if let Some(skip_ink) = v.flag("textDecorationSkipInk") { diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 8b3f02a0..4ae8c67a 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -25,6 +25,8 @@ mod source_identity_tests; #[cfg(test)] mod tests; #[cfg(test)] +mod text_paragraph_tests; +#[cfg(test)] mod text_skip_ink_tests; #[cfg(test)] mod text_style_tests; diff --git a/crates/format/src/fig/text_paragraph_tests.rs b/crates/format/src/fig/text_paragraph_tests.rs new file mode 100644 index 00000000..b3f05b05 --- /dev/null +++ b/crates/format/src/fig/text_paragraph_tests.rs @@ -0,0 +1,288 @@ +//! Paragraph options (list spacing, hanging list and punctuation, leading trim) in encoded `.fig` +//! files: the highest valid value wins, invalid instance rows fall through, native saves keep them. + +use super::kiwi::Kv; +use super::{export, import}; + +fn paragraph_fig(shared: Vec<(&'static str, Kv<'static>)>, local: Vec<(&'static str, Kv<'static>)>) -> Vec { + use super::kiwi::{Schema, encode}; + + let schema = Schema::parse( + "enum NodeType NONE DOCUMENT CANVAS TEXT\n\ + enum StyleType NONE FILL TEXT\n\ + enum LeadingTrim NONE CAP_HEIGHT\n\ + struct GUID uint sessionID, uint localID\n\ + struct ParentIndex GUID guid, string position\n\ + struct FontName string family, string style, string postscript\n\ + struct StyleId GUID guid\n\ + message TextData string characters\n\ + message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, StyleType styleType, FontName fontName, float fontSize, StyleId styleIdForText, TextData textData, float paragraphSpacing, float paragraphIndent, float listSpacing, bool hangingList, bool hangingPunctuation, LeadingTrim leadingTrim\n\ + message Message NodeChange[] nodeChanges", + ) + .unwrap(); + fn object(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) + } + fn guid(id: u64) -> Kv<'static> { + object(vec![("sessionID", Kv::Uint(7)), ("localID", Kv::Uint(id))]) + } + fn node(id: u64, ty: &'static str, name: &str, parent: Option, fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut out = vec![("guid", guid(id)), ("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))]; + if let Some(parent) = parent { + out.push(("parentIndex", object(vec![("guid", guid(parent)), ("position", Kv::Str("a".into()))]))); + } + out.extend(fields); + object(out) + } + fn font(family: &str) -> Kv<'static> { + object(vec![("family", Kv::Str(family.into())), ("style", Kv::Str("Regular".into())), ("postscript", Kv::Str(String::new()))]) + } + let mut base = vec![("styleType", Kv::Enum("TEXT")), ("fontName", font("Inter")), ("fontSize", Kv::Float(20.0))]; + base.extend(shared); + let mut layer = vec![ + ("fontName", font("Stale")), + ("fontSize", Kv::Float(40.0)), + ("styleIdForText", object(vec![("guid", guid(10))])), + ("textData", object(vec![("characters", Kv::Str("a\nb".into()))])), + ]; + layer.extend(local); + let msg = object(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, "DOCUMENT", "Document", None, vec![]), + node(2, "CANVAS", "Page", Some(1), vec![]), + node(10, "TEXT", "Shared", Some(2), base), + node(11, "TEXT", "Label", Some(2), layer), + ]), + )]); + let root = schema.find("Message").unwrap(); + let mut deflated = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + use std::io::Write; + deflated.write_all(&encode::schema(&schema)).unwrap(); + let schema_chunk = deflated.finish().unwrap(); + let message = encode::message(&schema, root, &msg).unwrap(); + let message_chunk = ruzstd::encoding::compress_to_vec(message.as_slice(), ruzstd::encoding::CompressionLevel::Fastest); + let mut bytes = b"fig-kiwi".to_vec(); + bytes.extend_from_slice(&101u32.to_le_bytes()); + for chunk in [schema_chunk, message_chunk] { + bytes.extend_from_slice(&(chunk.len() as u32).to_le_bytes()); + bytes.extend_from_slice(&chunk); + } + bytes +} + +#[test] +fn encoded_fig_maps_paragraph_list_fields_through_native_round_trip() { + let shared = vec![ + ("listSpacing", Kv::Float(18.0)), + ("hangingList", Kv::Bool(false)), + ("hangingPunctuation", Kv::Bool(false)), + ("leadingTrim", Kv::Enum("CAP_HEIGHT")), + ]; + let (inherited, warnings) = import(¶graph_fig(shared.clone(), vec![]), "Synthetic paragraph options").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let text = inherited.nodes().find(|node| node.name == "Label").unwrap().kind.text().unwrap(); + assert_eq!(text.list_spacing, 18.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::CapHeight); + assert_eq!(text.style.font_family, "Inter"); + + let reset = vec![ + ("listSpacing", Kv::Float(0.0)), + ("hangingList", Kv::Bool(false)), + ("hangingPunctuation", Kv::Bool(false)), + ("leadingTrim", Kv::Enum("NONE")), + ]; + let (cleared, warnings) = import(¶graph_fig(shared, reset), "Synthetic paragraph resets").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let text = cleared.nodes().find(|node| node.name == "Label").unwrap().kind.text().unwrap(); + assert_eq!(text.list_spacing, 0.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::None); + assert_eq!(text.style.font_family, "Inter"); + + let bytes = crate::save_bytes(&inherited, None).unwrap(); + let (back, warnings) = crate::load_bytes(&bytes).unwrap(); + assert!(warnings.is_empty(), "native round trip: {warnings:?}"); + let text = back.nodes().find(|node| node.name == "Label").unwrap().kind.text().unwrap(); + assert_eq!(text.list_spacing, 18.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::CapHeight); + + let (_, export_warnings) = export(&inherited, None).unwrap(); + assert!( + export_warnings.iter().any(|warning| warning.contains("list spacing, hanging punctuation, leading trim")), + "existing .fig export loss must remain visible: {export_warnings:?}" + ); +} + +fn instance_paragraph_fig(overrides: Vec<(&'static str, Kv<'static>)>, nonfinite_spacing: bool) -> Vec { + use super::kiwi::{Schema, encode}; + + let schema = Schema::parse( + "enum NodeType NONE DOCUMENT CANVAS TEXT SYMBOL INSTANCE\n\ + enum StyleType NONE FILL TEXT\n\ + enum LeadingTrim NONE CAP_HEIGHT\n\ + struct GUID uint sessionID, uint localID\n\ + struct ParentIndex GUID guid, string position\n\ + struct FontName string family, string style, string postscript\n\ + struct StyleId GUID guid\n\ + struct GUIDPath GUID[] guids\n\ + struct SymbolOverride GUIDPath guidPath, float listSpacing, bool hangingList, bool hangingPunctuation, LeadingTrim leadingTrim\n\ + struct SymbolData GUID symbolID, SymbolOverride[] symbolOverrides\n\ + message TextData string characters\n\ + message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, StyleType styleType, FontName fontName, float fontSize, StyleId styleIdForText, TextData textData, float listSpacing, bool hangingList, bool hangingPunctuation, LeadingTrim leadingTrim, SymbolData symbolData\n\ + message Message NodeChange[] nodeChanges", + ) + .unwrap(); + fn object(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) + } + fn guid(id: u64) -> Kv<'static> { + object(vec![("sessionID", Kv::Uint(7)), ("localID", Kv::Uint(id))]) + } + fn node(id: u64, ty: &'static str, name: &str, parent: Option, fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut out = vec![("guid", guid(id)), ("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))]; + if let Some(parent) = parent { + out.push(("parentIndex", object(vec![("guid", guid(parent)), ("position", Kv::Str("a".into()))]))); + } + out.extend(fields); + object(out) + } + fn font() -> Kv<'static> { + object(vec![("family", Kv::Str("Inter".into())), ("style", Kv::Str("Regular".into())), ("postscript", Kv::Str(String::new()))]) + } + let main_text = node( + 21, + "TEXT", + "Label", + Some(20), + vec![ + ("fontName", font()), + ("fontSize", Kv::Float(20.0)), + ("styleIdForText", object(vec![("guid", guid(10))])), + ("textData", object(vec![("characters", Kv::Str("a\nb".into()))])), + ], + ); + let mut override_fields = vec![("guidPath", object(vec![("guids", Kv::Array(vec![guid(21)]))]))]; + override_fields.extend(overrides); + let instance = node( + 30, + "INSTANCE", + "Copy", + Some(2), + vec![("symbolData", object(vec![("symbolID", guid(20)), ("symbolOverrides", Kv::Array(vec![object(override_fields)]))]))], + ); + let message = object(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, "DOCUMENT", "Document", None, vec![]), + node(2, "CANVAS", "Page", Some(1), vec![]), + node( + 10, + "TEXT", + "Shared", + Some(2), + vec![ + ("styleType", Kv::Enum("TEXT")), + ("fontName", font()), + ("fontSize", Kv::Float(20.0)), + ("listSpacing", Kv::Float(18.0)), + ("hangingList", Kv::Bool(true)), + ("hangingPunctuation", Kv::Bool(true)), + ("leadingTrim", Kv::Enum("CAP_HEIGHT")), + ], + ), + node(20, "SYMBOL", "Main", Some(2), vec![]), + main_text, + instance, + ]), + )]); + let root = schema.find("Message").unwrap(); + let mut deflated = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + use std::io::Write; + deflated.write_all(&encode::schema(&schema)).unwrap(); + let schema_chunk = deflated.finish().unwrap(); + let mut message = encode::message(&schema, root, &message).unwrap(); + if nonfinite_spacing { + let finite = 37.0_f32.to_bits().rotate_right(23).to_le_bytes(); + let nan = f32::NAN.to_bits().rotate_right(23).to_le_bytes(); + let matches: Vec = message.windows(finite.len()).enumerate().filter_map(|(i, w)| (w == finite).then_some(i)).collect(); + assert_eq!(matches.len(), 1, "finite test token must identify the instance listSpacing field"); + let offset = matches[0]; + message[offset..offset + nan.len()].copy_from_slice(&nan); + } + let message_chunk = ruzstd::encoding::compress_to_vec(message.as_slice(), ruzstd::encoding::CompressionLevel::Fastest); + let mut bytes = b"fig-kiwi".to_vec(); + bytes.extend_from_slice(&101u32.to_le_bytes()); + for chunk in [schema_chunk, message_chunk] { + bytes.extend_from_slice(&(chunk.len() as u32).to_le_bytes()); + bytes.extend_from_slice(&chunk); + } + bytes +} + +#[test] +fn encoded_instance_paragraph_overrides_fall_back_only_when_invalid() { + let valid = vec![ + ("listSpacing", Kv::Float(24.0)), + ("hangingList", Kv::Bool(false)), + ("hangingPunctuation", Kv::Bool(false)), + ("leadingTrim", Kv::Enum("NONE")), + ]; + let (doc, warnings) = import(&instance_paragraph_fig(valid, false), "Synthetic instance paragraph values").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let copy = doc.nodes().find(|node| node.name == "Copy").unwrap(); + let label = doc.node(*copy.children.first().unwrap()).unwrap(); + let text = label.kind.text().unwrap(); + assert_eq!(text.list_spacing, 24.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::None); + + let reset = vec![ + ("listSpacing", Kv::Float(0.0)), + ("hangingList", Kv::Bool(false)), + ("hangingPunctuation", Kv::Bool(false)), + ("leadingTrim", Kv::Enum("NONE")), + ]; + let (doc, warnings) = import(&instance_paragraph_fig(reset, false), "Synthetic instance paragraph resets").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let copy = doc.nodes().find(|node| node.name == "Copy").unwrap(); + let label = doc.node(*copy.children.first().unwrap()).unwrap(); + let text = label.kind.text().unwrap(); + assert_eq!(text.list_spacing, 0.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::None); + + let invalid = vec![ + ("listSpacing", Kv::Float(37.0)), + ("hangingList", Kv::Bool(false)), + ("hangingPunctuation", Kv::Bool(false)), + ("leadingTrim", Kv::Uint(77)), + ]; + let (doc, warnings) = import(&instance_paragraph_fig(invalid, true), "Synthetic malformed instance paragraph values").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let copy = doc.nodes().find(|node| node.name == "Copy").unwrap(); + let label = doc.node(*copy.children.first().unwrap()).unwrap(); + let text = label.kind.text().unwrap(); + assert_eq!(text.list_spacing, 18.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::CapHeight); + + let bytes = crate::save_bytes(&doc, None).unwrap(); + let (back, warnings) = crate::load_bytes(&bytes).unwrap(); + assert!(warnings.is_empty(), "native round trip: {warnings:?}"); + let copy = back.nodes().find(|node| node.name == "Copy").unwrap(); + let label = back.node(*copy.children.first().unwrap()).unwrap(); + let text = label.kind.text().unwrap(); + assert_eq!(text.list_spacing, 18.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::CapHeight); +} From c4536f412b950ac12cb87350323180a9ea028fbe Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:46:01 +0200 Subject: [PATCH 018/118] M1: import labels of straight connectors from .fig files Straight connectors saved with a manual routing snapshot (connector and derived-frame version 1) now come in as an editable vector plus their label text layers, instead of an empty frame. Label placement composes the saved background and text transforms, and label paints and text styles use the existing import path. Connectors of other versions or line styles keep the existing fallback with their compatibility notes. At the layer depth or node limit the saved vector is kept without extra label layers, and skipped labels are reported once. Live endpoint bindings and routing are not retained. --- .../format/src/fig/connector_label_tests.rs | 308 ++++++++++++++++++ crates/format/src/fig/map.rs | 262 ++++++++++++++- .../map_connector_snapshot_budget_tests.rs | 145 +++++++++ crates/format/src/fig/mod.rs | 2 + 4 files changed, 712 insertions(+), 5 deletions(-) create mode 100644 crates/format/src/fig/connector_label_tests.rs create mode 100644 crates/format/src/fig/map_connector_snapshot_budget_tests.rs diff --git a/crates/format/src/fig/connector_label_tests.rs b/crates/format/src/fig/connector_label_tests.rs new file mode 100644 index 00000000..56cea368 --- /dev/null +++ b/crates/format/src/fig/connector_label_tests.rs @@ -0,0 +1,308 @@ +use super::{Archive, decode_canvas, kiwi::Kv, map}; + +fn obj(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} + +fn scalar(value: f32) -> Kv<'static> { + Kv::Float(value) +} + +fn guid(local_id: u64) -> Kv<'static> { + obj(vec![("sessionID", Kv::Uint(40_000_000)), ("localID", Kv::Uint(local_id))]) +} + +fn record(local_id: u64, children: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut fields = vec![("guidPath", obj(vec![("guids", Kv::Array(vec![guid(local_id)]))]))]; + fields.extend(children); + obj(fields) +} + +fn transform(x: f32, y: f32) -> Kv<'static> { + obj(vec![("m00", scalar(1.0)), ("m01", scalar(0.0)), ("m02", scalar(x)), ("m10", scalar(0.0)), ("m11", scalar(1.0)), ("m12", scalar(y))]) +} + +fn point(x: f32, y: f32) -> Kv<'static> { + obj(vec![("x", scalar(x)), ("y", scalar(y))]) +} + +fn path_blob() -> Vec { + let mut bytes = vec![1]; // MoveTo + bytes.extend(0.0f32.to_le_bytes()); + bytes.extend(0.0f32.to_le_bytes()); + bytes.push(2); // LineTo + bytes.extend(20.0f32.to_le_bytes()); + bytes.extend(10.0f32.to_le_bytes()); + bytes +} + +fn paint() -> Kv<'static> { + obj(vec![ + ("type", Kv::Enum("SOLID")), + ("color", obj(vec![("r", scalar(0.1)), ("g", scalar(0.2)), ("b", scalar(0.8)), ("a", scalar(1.0))])), + ("visible", Kv::Bool(true)), + ]) +} + +fn synthetic_connector(version: f32, include_text: bool, text_x: f32) -> Kv<'static> { + let line_geometry = Kv::Array(vec![obj(vec![("commandsBlob", Kv::Uint(0))])]); + let line = record(0, vec![("transform", transform(7.0, 9.0)), ("size", point(20.0, 10.0)), ("fillGeometry", line_geometry)]); + let text = record(1, vec![("transform", transform(text_x, 3.0)), ("size", point(80.0, 24.0))]); + let background = record(2, vec![("transform", transform(100.0, 30.0)), ("size", point(90.0, 30.0))]); + let mut derived = vec![line]; + if include_text { + derived.extend([text, background]); + } + let line_style = record(0, vec![("strokePaints", Kv::Array(vec![paint()]))]); + let text_style = record( + 1, + vec![ + ("textData", obj(vec![("characters", Kv::Str("synthetic label".into()))])), + ("fontSize", scalar(24.0)), + ("fontName", obj(vec![("family", Kv::Str("Inter".into())), ("style", Kv::Str("Regular".into()))])), + ("fillPaints", Kv::Array(vec![paint()])), + ("visible", Kv::Bool(false)), + ], + ); + let bg_style = record(2, vec![("fillPaints", Kv::Array(vec![])), ("visible", Kv::Bool(false))]); + let mut styles = vec![line_style]; + if include_text { + styles.extend([text_style, bg_style]); + } + obj(vec![ + ( + "nodeChanges", + Kv::Array(vec![ + obj(vec![("guid", guid(0)), ("type", Kv::Enum("DOCUMENT")), ("name", Kv::Str("Doc".into()))]), + obj(vec![ + ("guid", guid(1)), + ("parentIndex", obj(vec![("guid", guid(0)), ("position", Kv::Str("!".into()))])), + ("type", Kv::Enum("CANVAS")), + ("name", Kv::Str("Page".into())), + ]), + obj(vec![ + ("guid", obj(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(123))])), + ("parentIndex", obj(vec![("guid", guid(1)), ("position", Kv::Str("!".into()))])), + ("type", Kv::Enum("CONNECTOR")), + ("name", Kv::Str("synthetic connector".into())), + ("size", point(140.0, 50.0)), + ("opacity", scalar(0.5)), + ("connectorType", Kv::Enum("MANUAL")), + ("connectorVersion", scalar(version)), + ("connectorLineStyle", Kv::Enum("STRAIGHT")), + ("connectorStartCap", Kv::Enum("NONE")), + ("connectorEndCap", Kv::Enum("ARROW")), + ("derivedImmutableFrameData", obj(vec![("version", scalar(1.0)), ("overrides", Kv::Array(derived))])), + ("nodeGenerationData", obj(vec![("overrides", Kv::Array(styles))])), + ]), + ]), + ), + ("blobs", Kv::Array(vec![obj(vec![("bytes", Kv::Bytes(path_blob()))])])), + ]) +} + +fn map_synthetic(version: f32, include_text: bool, text_x: f32) -> (serde_json::Value, Vec) { + map::to_rest(&synthetic_connector(version, include_text, text_x), "synthetic") +} + +fn find_node<'a>(doc: &'a figcraft_doc::Document, name: &str) -> &'a figcraft_doc::Node { + doc.nodes().find(|node| node.name == name).expect("native node") +} + +#[test] +fn synthetic_label_style_transform_and_container_opacity_survive_native_roundtrip() { + let (rest, warnings) = map_synthetic(1.0, true, 8.5); + let connector = &rest["document"]["children"][0]["children"][0]; + assert_eq!(connector["type"], "FRAME"); + assert_eq!(connector["opacity"], 0.5); + assert_eq!(connector["children"].as_array().unwrap().len(), 2, "empty/hidden background is omitted; hidden text remains explicit"); + let text = &connector["children"][1]; + assert_eq!(text["type"], "TEXT"); + assert_eq!(text["visible"], false); + assert_eq!(text["fills"].as_array().unwrap().len(), 1); + assert!((text["fills"][0]["color"]["b"].as_f64().unwrap() - 0.8).abs() < 1e-6); + assert_eq!(text["style"]["fontSize"], 24.0); + assert_eq!(text["relativeTransform"], serde_json::json!([[1.0, 0.0, 108.5], [0.0, 1.0, 33.0]])); + assert!(warnings.iter().any(|warning| warning.contains("live endpoint bindings are not retained"))); + + let (native, _) = crate::rest::import_fig(&rest, &|_| None).unwrap(); + let bytes = crate::save_bytes(&native, None).unwrap(); + let (reopened, _) = crate::load_bytes(&bytes).unwrap(); + let frame = find_node(&reopened, "synthetic connector"); + assert_eq!(frame.opacity, 0.5); + let text = find_node(&reopened, "synthetic label"); + assert!(!text.visible); + assert_eq!(text.opacity, 1.0, "root opacity must not be copied to child"); + assert_eq!(text.transform.as_coeffs()[4..], [108.5, 33.0]); + assert_eq!(text.fills.len(), 1); + let figcraft_doc::NodeKind::Text(data) = &text.kind else { panic!("text child kind") }; + assert_eq!(data.style.font_size, 24.0); +} + +#[test] +fn synthetic_connector_missing_optional_derived_label_keeps_vector() { + let (rest, warnings) = map_synthetic(1.0, false, 0.0); + let connector = &rest["document"]["children"][0]["children"][0]; + assert_eq!(connector["type"], "VECTOR", "an unlabelled connector keeps the single-vector shape"); + assert!(connector.get("children").is_none()); + assert!(warnings.iter().all(|warning| !warning.contains("connector labels were skipped"))); +} + +#[test] +fn synthetic_unlabelled_connector_imports_as_one_vector_with_line_style() { + let (rest, warnings) = map_synthetic(1.0, false, 0.0); + let connector = &rest["document"]["children"][0]["children"][0]; + assert_eq!(connector["type"], "VECTOR"); + assert_eq!(connector["name"], "synthetic connector"); + assert_eq!(connector["opacity"], 0.5); + assert_eq!(connector["strokes"].as_array().map(Vec::len), Some(1)); + assert_eq!(connector["strokeCapEnd"], "ARROW"); + assert!(warnings.iter().all(|warning| !warning.contains("connector label")), "{warnings:?}"); + + let (native, _) = crate::rest::import_fig(&rest, &|_| None).unwrap(); + let node = find_node(&native, "synthetic connector"); + assert!(matches!(node.kind, figcraft_doc::NodeKind::Vector { .. })); +} + +#[test] +fn synthetic_connector_unsupported_version_uses_legacy_warning() { + let (rest, warnings) = map_synthetic(2.0, true, 8.5); + let connector = &rest["document"]["children"][0]["children"][0]; + assert_eq!(connector["type"], "VECTOR"); + assert!(warnings.iter().any(|warning| warning == "connector labels were skipped")); +} + +#[test] +fn synthetic_connector_malformed_label_bounds_or_transform_keep_vector() { + let mut malformed_bounds = synthetic_connector(1.0, true, 8.5); + set_override_field(&mut malformed_bounds, "derivedImmutableFrameData", 1, "size", point(f32::NAN, 24.0)); + let (rest, warnings) = map::to_rest(&malformed_bounds, "synthetic"); + let connector = &rest["document"]["children"][0]["children"][0]; + assert_eq!(connector["children"].as_array().unwrap().len(), 1); + assert_eq!(connector["children"][0]["type"], "VECTOR"); + assert!(warnings.iter().any(|warning| warning == "connector label was skipped: text bounds are invalid")); + + let mut malformed_transform = synthetic_connector(1.0, true, 8.5); + set_override_field(&mut malformed_transform, "derivedImmutableFrameData", 1, "transform", transform(f32::NAN, 3.0)); + let (rest, warnings) = map::to_rest(&malformed_transform, "synthetic"); + let connector = &rest["document"]["children"][0]["children"][0]; + assert_eq!(connector["children"].as_array().unwrap().len(), 1); + assert_eq!(connector["children"][0]["type"], "VECTOR"); + assert!(warnings.iter().any(|warning| warning == "connector label was skipped: text transform is invalid")); +} + +#[test] +fn synthetic_connector_authored_hidden_background_is_retained() { + let mut message = synthetic_connector(1.0, true, 8.5); + set_override_field(&mut message, "nodeGenerationData", 2, "fillPaints", Kv::Array(vec![paint()])); + let (rest, _) = map::to_rest(&message, "synthetic"); + let connector = &rest["document"]["children"][0]["children"][0]; + let background = connector["children"].as_array().unwrap().iter().find(|child| child["type"] == "RECTANGLE").expect("authored background"); + assert_eq!(background["visible"], false); + assert_eq!(background["fills"].as_array().unwrap().len(), 1); +} + +fn connector<'a>(layers: &'a [serde_json::Value], name: &str) -> &'a serde_json::Value { + layers.iter().find(|layer| layer.get("name").and_then(serde_json::Value::as_str) == Some(name)).expect("connector in fixture") +} + +fn field_mut<'a, 's>(value: &'a mut Kv<'s>, key: &str) -> Option<&'a mut Kv<'s>> { + let Kv::Object(fields) = value else { return None }; + fields.iter_mut().find(|(name, _)| *name == key).map(|(_, value)| value) +} + +fn connector_mut<'a, 's>(message: &'a mut Kv<'s>, local_id: u64) -> Option<&'a mut Kv<'s>> { + let Kv::Object(fields) = message else { return None }; + let node_changes = fields.iter_mut().find(|(name, _)| *name == "nodeChanges").map(|(_, value)| value)?; + let Kv::Array(nodes) = node_changes else { return None }; + nodes.iter_mut().find(|node| { + node.get("type").and_then(Kv::str) == Some("CONNECTOR") + && node.get("guid").and_then(|guid| guid.get("localID")).and_then(Kv::f64) == Some(local_id as f64) + }) +} + +fn set_override_field(message: &mut Kv<'_>, field: &str, local_id: u64, key: &'static str, value: Kv<'static>) { + let connector = connector_mut(message, 123).expect("synthetic connector"); + let section = field_mut(connector, field).expect("connector override section"); + let overrides = field_mut(section, "overrides").expect("override list"); + let Kv::Array(rows) = overrides else { panic!("override list array") }; + let row = rows + .iter_mut() + .find(|row| { + row.get("guidPath") + .and_then(|path| path.get("guids")) + .and_then(|ids| ids.list().first()) + .and_then(|id| id.get("localID")) + .and_then(Kv::f64) + == Some(local_id as f64) + }) + .expect("override row"); + let Kv::Object(fields) = row else { panic!("override row object") }; + if let Some((_, current)) = fields.iter_mut().find(|(name, _)| *name == key) { + *current = value; + } else { + fields.push((key, value)); + } +} + +#[test] +#[ignore = "set FIGCRAFT_CONNECTOR_LABEL_FIXTURE to the retained synthetic source-initial.zip"] +fn synthetic_export_keeps_saved_vector_gap_and_composed_label_position() { + let path = std::env::var_os("FIGCRAFT_CONNECTOR_LABEL_FIXTURE").expect("fixture path is required when running this test"); + let bytes = std::fs::read(path).expect("read synthetic connector export"); + let archive = Archive::open(&bytes).expect("open synthetic connector export"); + let (schema, raw_message) = decode_canvas(&archive.canvas).expect("decode synthetic canvas"); + let root = schema.find("Message").expect("Message definition"); + let message = schema.decode_message(root, &raw_message).expect("decode synthetic message"); + let (rest, warnings) = map::to_rest(&message, "connector labels"); + let layers = rest.pointer("/document/children/0/children").and_then(serde_json::Value::as_array).expect("page layers"); + + let plain = connector(layers, "Connector line"); + assert_eq!(plain["type"], "VECTOR", "an unlabelled connector stays one vector"); + assert_eq!(plain["strokeCapStart"], "ROUND"); + assert_eq!(plain["strokeCapEnd"], "LINE_ARROW"); + assert!(plain.get("children").is_none()); + + let alpha = connector(layers, "STRAIGHT alpha"); + let alpha_children = alpha["children"].as_array().expect("snapshot children"); + let alpha_vector = alpha_children.iter().find(|child| child["type"] == "VECTOR").expect("saved connector vector"); + let segments = alpha_vector.pointer("/vectorNetwork/segments").and_then(serde_json::Value::as_array).expect("editable vector network"); + assert_eq!(segments.len(), 4, "network preserves the label gap as two disconnected runs"); + assert_eq!(alpha_children.iter().filter(|child| child["type"] == "TEXT").count(), 1); + let alpha_text = alpha_children.iter().find(|child| child["type"] == "TEXT").expect("label text"); + assert_eq!(alpha_text["characters"], "STRAIGHT alpha"); + assert_eq!(alpha_text["relativeTransform"], serde_json::json!([[1.0, 0.0, 201.5], [0.0, 1.0, 44.0]])); + assert!(warnings.iter().all(|warning| !warning.contains("connector labels were skipped"))); + + let mut malformed = message.clone(); + let alpha_node = connector_mut(&mut malformed, 127).expect("alpha node in decoded message"); + let derived = field_mut(alpha_node, "derivedImmutableFrameData").expect("derived frame data"); + let overrides = field_mut(derived, "overrides").expect("derived overrides"); + let Kv::Array(overrides) = overrides else { panic!("derived overrides array") }; + let text = overrides + .iter_mut() + .find(|row| { + row.get("guidPath") + .and_then(|path| path.get("guids")) + .and_then(|ids| ids.list().first()) + .and_then(|id| id.get("localID")) + .and_then(Kv::f64) + == Some(1.0) + }) + .expect("text template record"); + let transform = field_mut(text, "transform").expect("text transform"); + *transform = Kv::Object(vec![ + ("m00", Kv::Float(1.0)), + ("m01", Kv::Float(0.0)), + ("m02", Kv::Float(f32::NAN)), + ("m10", Kv::Float(0.0)), + ("m11", Kv::Float(1.0)), + ("m12", Kv::Float(0.0)), + ]); + let (malformed_rest, malformed_warnings) = map::to_rest(&malformed, "connector labels"); + let malformed_layers = malformed_rest.pointer("/document/children/0/children").and_then(serde_json::Value::as_array).expect("page layers"); + let malformed_alpha = connector(malformed_layers, "STRAIGHT alpha"); + assert_eq!(malformed_alpha["children"].as_array().map(Vec::len), Some(1)); + assert_eq!(malformed_alpha["children"][0]["type"], "VECTOR"); + assert!(malformed_warnings.iter().any(|warning| warning == "connector label was skipped: text transform is invalid")); +} diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 0fe44662..d6983a74 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -90,6 +90,10 @@ fn normalize_override_segment(path: &mut [String], index: usize, scope: &Overrid } } +#[cfg(test)] +#[path = "map_connector_snapshot_budget_tests.rs"] +mod connector_snapshot_budget_tests; + /// A `GUID`-holding reference (`StyleId`, `VariableID`, …): its `guid`. fn ref_guid(v: &Kv) -> Option { v.get("guid").and_then(guid).filter(|g| g != NO_STYLE) @@ -750,18 +754,28 @@ impl<'a> Mapper<'a> { _ => id.to_string(), }; let own = view.str("type").unwrap_or(""); - let stand = stand_in(own); + let connector_snapshot = own == "CONNECTOR" && self.connector_snapshot_supported(&view); + // Only a connector with a saved label needs the frame wrapper; an unlabelled one stays a single vector. + let connector_terminal = + connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= MAX_NODES); + // A supported connector snapshot has its own note below instead of the generic stand-in one. + let stand = stand_in(own).filter(|_| !connector_snapshot); let ty = match own { "ROUNDED_RECTANGLE" => "RECTANGLE", "SYMBOL" => "COMPONENT", "FRAME" if view.flag("isStateGroup") == Some(true) => "COMPONENT_SET", "FRAME" if view.flag("resizeToFit") == Some(true) => "GROUP", "VARIABLE" | "VARIABLE_SET" => return None, + "CONNECTOR" if connector_snapshot && connector_terminal => "VECTOR", + "CONNECTOR" if connector_snapshot => "FRAME", t => stand.map_or(t, |s| s.0), }; if let Some((_, what)) = stand { self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " "))); } + if connector_snapshot { + self.warn("connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained"); + } self.count += 1; let mut o = Obj::new(); o.insert("id".into(), json!(rest_id)); @@ -790,7 +804,18 @@ impl<'a> Mapper<'a> { } _ => {} } - if own == "CONNECTOR" { + if own == "CONNECTOR" && connector_snapshot { + if connector_terminal { + self.connector_snapshot_flatten(&mut o, &view, &rest_id, space); + } else { + o.insert("fills".into(), json!([])); + o.insert("strokes".into(), json!([])); + for key in ["styles", "strokeWeight", "strokeAlign", "strokeJoin", "strokeDashes", "strokeCap"] { + o.remove(key); + } + o.insert("clipsContent".into(), json!(false)); + } + } else if own == "CONNECTOR" { self.connector(&mut o, &view, space); } if ty == "COMPONENT" || ty == "COMPONENT_SET" { @@ -798,8 +823,10 @@ impl<'a> Mapper<'a> { } self.prop_refs(&mut o, &view, inst); let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { space * view.transform().unwrap_or_default() } else { Affine::IDENTITY }; - let mut kids = Vec::new(); - if ty == "INSTANCE" { + let mut kids = if connector_snapshot && !connector_terminal { self.connector_snapshot_children(&view, &rest_id) } else { Vec::new() }; + if connector_terminal { + // The saved vector is flattened into the connector node when no child slot remains. + } else if ty == "INSTANCE" { if let Some((sub, sym)) = self.instance(&mut o, &view, id, inst, &rest_id) { let main = self.nodes.get(&sym).map(|m| View::of(m)); let fit = Fit::frame(main.and_then(|m| m.size()), view.size()); @@ -898,7 +925,7 @@ impl<'a> Mapper<'a> { o.insert(to.into(), json!(cap_name(c))); } } - if own_text(v).is_some() { + if connector_has_saved_label(v) { self.warn("connector labels were skipped"); } if ["vectorNetwork", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) { @@ -938,6 +965,207 @@ impl<'a> Mapper<'a> { ); } + fn connector_snapshot_supported(&self, v: &View<'a>) -> bool { + if v.str("connectorType") != Some("MANUAL") + || v.get("connectorVersion").and_then(Kv::f64) != Some(1.0) + || v.str("connectorLineStyle") != Some("STRAIGHT") + || v.get("derivedImmutableFrameData").and_then(|data| data.get("version")).and_then(Kv::f64) != Some(1.0) + { + return false; + } + let Some(line) = connector_record(v, "derivedImmutableFrameData", 0) else { return false }; + if line.get("transform").and_then(matrix).is_none() || valid_size(line.get("size")).is_none() { + return false; + } + let geometry = ["fillGeometry", "strokeGeometry"].iter().any(|key| { + line.get(key).map(Kv::list).unwrap_or_default().iter().any(|path| { + num(path, "commandsBlob") + .and_then(|index| self.blobs.get(index as usize)) + .and_then(|blob| svg_path(blob)) + .is_some_and(|data| !data.is_empty()) + }) + }); + let network = line + .get("vectorData") + .and_then(|data| num(data, "vectorNetworkBlob")) + .and_then(|index| self.blobs.get(index as usize)) + .and_then(|blob| network::decode_styled(blob)) + .is_some_and(|(network, _)| !network.segments.is_empty()); + (geometry || network) && connector_record(v, "nodeGenerationData", 0).is_some() + } + + fn connector_snapshot_flatten(&mut self, out: &mut Obj, v: &View<'a>, rest_id: &str, space: Affine) { + let parent = space * v.get("transform").and_then(matrix).unwrap_or_default(); + let Some(vector) = self.connector_snapshot_vector(v, rest_id, parent, false) else { + self.warn("connector snapshot vector data is missing; connector was left empty"); + return; + }; + for key in [ + "size", + "relativeTransform", + "fills", + "strokes", + "strokeWeight", + "strokeAlign", + "strokeJoin", + "strokeDashes", + "strokeCap", + "strokeCapStart", + "strokeCapEnd", + "styles", + "fillGeometry", + "strokeGeometry", + "vectorNetwork", + ] { + if let Some(value) = vector.get(key) { + out.insert(key.to_string(), value.clone()); + } + } + if vector.get("visible").and_then(Value::as_bool) == Some(false) { + out.insert("visible".into(), json!(false)); + } + if let Some(line_opacity) = vector.get("opacity").and_then(Value::as_f64) { + let root_opacity = out.get("opacity").and_then(Value::as_f64).unwrap_or(1.0); + out.insert("opacity".into(), json!(root_opacity * line_opacity)); + } + if connector_has_saved_label(v) { + let reason = if self.count >= MAX_NODES { "node budget" } else { "depth limit" }; + self.warn(format!("connector label was skipped: {reason} prevents nested snapshot children")); + } + } + + /// Build the saved vector child or flattened terminal connector from the same child-only view. + fn connector_snapshot_vector(&mut self, v: &View<'a>, rest_id: &str, space: Affine, nested: bool) -> Option { + let (Some(line), Some(line_style)) = (connector_record(v, "derivedImmutableFrameData", 0), connector_record(v, "nodeGenerationData", 0)) + else { + return None; + }; + let mut vector = Obj::new(); + vector.insert("id".into(), json!(if nested { format!("{rest_id};vector") } else { rest_id.to_string() })); + vector.insert("type".into(), json!("VECTOR")); + if let Some(name) = v.str("name") { + vector.insert("name".into(), json!(if nested { format!("{name} vector") } else { name.to_string() })); + } + let line_view = View::of(line).with([line_style]); + self.common(&mut vector, &line_view, "VECTOR", space, None); + self.geometry(&mut vector, &line_view); + for (from, to) in [("connectorStartCap", "strokeCapStart"), ("connectorEndCap", "strokeCapEnd")] { + if let Some(cap) = v.str(from) { + vector.entry(to).or_insert_with(|| json!(cap_name(cap))); + } + } + Some(vector) + } + + /// Snapshot the straight, manual connector contract carried by its generated children. + fn connector_snapshot_children(&mut self, v: &View<'a>, rest_id: &str) -> Vec { + if self.count >= MAX_NODES { + self.warn(format!("the file has more than {MAX_NODES} layers; connector snapshot children were skipped")); + return Vec::new(); + } + let Some(vector) = self.connector_snapshot_vector(v, rest_id, Affine::IDENTITY, true) else { + self.warn("connector snapshot vector data is missing; connector was left empty"); + return Vec::new(); + }; + let mut children = Vec::new(); + children.push(Value::Object(vector)); + self.count += 1; + + let Some(text_frame) = connector_record(v, "derivedImmutableFrameData", 1) else { + if connector_has_saved_label(v) { + self.warn("connector label was skipped: derived text bounds are missing"); + } + return children; + }; + let Some(background) = connector_record(v, "derivedImmutableFrameData", 2) else { + if connector_has_saved_label(v) || valid_size(text_frame.get("size")).is_some_and(|(w, h)| w > 0.0 && h > 0.0) { + self.warn("connector label was skipped: derived background bounds are missing"); + } + return children; + }; + let Some(text_style) = connector_record(v, "nodeGenerationData", 1) else { + if valid_size(text_frame.get("size")).is_some_and(|(w, h)| w > 0.0 && h > 0.0) { + self.warn("connector label was skipped: text style data is missing"); + } + return children; + }; + let text_view = View::of(text_frame).with([text_style]); + let Some(characters) = text_view.get("textData").and_then(|d| d.get("characters")).and_then(Kv::str).filter(|s| !s.trim().is_empty()) else { + return children; + }; + let Some(text_transform) = text_frame.get("transform").and_then(matrix) else { + self.warn("connector label was skipped: text transform is invalid"); + return children; + }; + let Some(background_transform) = background.get("transform").and_then(matrix) else { + self.warn("connector label was skipped: background transform is invalid"); + return children; + }; + let Some(text_size) = valid_size(text_frame.get("size")) else { + self.warn("connector label was skipped: text bounds are invalid"); + return children; + }; + let Some(background_size) = valid_size(background.get("size")) else { + self.warn("connector label was skipped: background bounds are invalid"); + return children; + }; + let composed_text_transform = background_transform * text_transform; + if !composed_text_transform.as_coeffs().iter().all(|coefficient| coefficient.is_finite()) { + self.warn("connector label was skipped: composed text transform is invalid"); + return children; + } + + let Some(background_style) = connector_record(v, "nodeGenerationData", 2) else { + self.warn("connector label background was skipped: style data is missing"); + self.add_connector_text(&mut children, rest_id, characters, text_size, composed_text_transform, &text_view); + return children; + }; + let background_view = View::of(background).with([background_style]); + if !background_view.list("fillPaints").is_empty() { + if self.count >= MAX_NODES { + self.warn(format!("the file has more than {MAX_NODES} layers; connector label background was skipped")); + } else { + let mut rect = Obj::new(); + rect.insert("id".into(), json!(format!("{rest_id};label-background"))); + rect.insert("type".into(), json!("RECTANGLE")); + rect.insert("name".into(), json!("connector label background")); + self.common(&mut rect, &background_view, "RECTANGLE", Affine::IDENTITY, None); + rect.insert("size".into(), json!({"x": background_size.0, "y": background_size.1})); + rect.insert("relativeTransform".into(), rows(background_transform)); + rect.insert("clipsContent".into(), json!(false)); + children.push(Value::Object(rect)); + self.count += 1; + } + } + self.add_connector_text(&mut children, rest_id, characters, text_size, composed_text_transform, &text_view); + children + } + + fn add_connector_text( + &mut self, + children: &mut Vec, + rest_id: &str, + characters: &str, + size: (f64, f64), + transform: Affine, + view: &View<'a>, + ) { + if self.count >= MAX_NODES { + self.warn(format!("the file has more than {MAX_NODES} layers; connector label was skipped")); + return; + } + let mut text = Obj::new(); + text.insert("id".into(), json!(format!("{rest_id};label-text"))); + text.insert("type".into(), json!("TEXT")); + text.insert("name".into(), json!(characters)); + text.insert("size".into(), json!({"x": size.0, "y": size.1})); + self.common(&mut text, view, "TEXT", Affine::IDENTITY, None); + text.insert("relativeTransform".into(), rows(transform)); + self.text(&mut text, view); + children.push(Value::Object(text)); + self.count += 1; + } + /// Fields every layer type shares. fn common(&mut self, o: &mut Obj, v: &View<'a>, ty: &str, space: Affine, parent: Option<&View<'a>>) { for (from, to) in [("visible", "visible"), ("locked", "locked"), ("mask", "isMask")] { @@ -1852,6 +2080,30 @@ fn own_text<'a>(v: &View<'a>) -> Option<&'a str> { v.get("textData").and_then(|t| t.get("characters")).and_then(Kv::str).filter(|c| !c.trim().is_empty()) } +/// A generated connector child is identified by one exact template GUID, not its array position. +fn connector_record<'a>(v: &View<'a>, field: &str, local_id: u32) -> Option<&'a Kv<'a>> { + v.get(field)?.get("overrides")?.list().iter().find(|row| { + row.get("guidPath").and_then(|path| path.get("guids")).map(Kv::list).is_some_and(|guids| { + guids.len() == 1 && num(&guids[0], "sessionID") == Some(40_000_000.0) && num(&guids[0], "localID") == Some(f64::from(local_id)) + }) + }) +} + +fn connector_has_saved_label(v: &View<'_>) -> bool { + own_text(v).is_some() + || connector_record(v, "nodeGenerationData", 1) + .and_then(|r| r.get("textData")) + .and_then(|d| d.get("characters")) + .and_then(Kv::str) + .is_some_and(|s| !s.trim().is_empty()) +} + +fn valid_size(value: Option<&Kv<'_>>) -> Option<(f64, f64)> { + let x = num(value?, "x")?; + let y = num(value?, "y")?; + (x.is_finite() && y.is_finite() && x >= 0.0 && y >= 0.0).then_some((x, y)) +} + /// The REST name of a stroke cap. fn cap_name(cap: &str) -> &str { match cap { diff --git a/crates/format/src/fig/map_connector_snapshot_budget_tests.rs b/crates/format/src/fig/map_connector_snapshot_budget_tests.rs new file mode 100644 index 00000000..a14e671c --- /dev/null +++ b/crates/format/src/fig/map_connector_snapshot_budget_tests.rs @@ -0,0 +1,145 @@ +//! Connector snapshot flattening stays within the node and depth budgets. + +use super::{MAX_DEPTH, MAX_NODES, Mapper, View}; +use crate::fig::kiwi::Kv; +use figcraft_doc::geom::Affine; + +fn obj(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} + +fn guid(local_id: u64) -> Kv<'static> { + obj(vec![("sessionID", Kv::Uint(40_000_000)), ("localID", Kv::Uint(local_id))]) +} + +fn path_blob() -> Vec { + let mut bytes = vec![1]; + bytes.extend(0.0f32.to_le_bytes()); + bytes.extend(0.0f32.to_le_bytes()); + bytes.push(2); + bytes.extend(20.0f32.to_le_bytes()); + bytes.extend(10.0f32.to_le_bytes()); + bytes +} + +fn record(local_id: u64, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + fields.push(("guidPath", obj(vec![("guids", Kv::Array(vec![guid(local_id)]))]))); + obj(fields) +} + +fn message() -> Kv<'static> { + let line = record( + 0, + vec![ + ( + "transform", + obj(vec![ + ("m00", Kv::Float(1.0)), + ("m01", Kv::Float(0.0)), + ("m02", Kv::Float(0.0)), + ("m10", Kv::Float(0.0)), + ("m11", Kv::Float(1.0)), + ("m12", Kv::Float(0.0)), + ]), + ), + ("size", obj(vec![("x", Kv::Float(20.0)), ("y", Kv::Float(10.0))])), + ("fillGeometry", Kv::Array(vec![obj(vec![("commandsBlob", Kv::Uint(0))])])), + ], + ); + let text = record( + 1, + vec![ + ( + "transform", + obj(vec![ + ("m00", Kv::Float(1.0)), + ("m01", Kv::Float(0.0)), + ("m02", Kv::Float(0.0)), + ("m10", Kv::Float(0.0)), + ("m11", Kv::Float(1.0)), + ("m12", Kv::Float(0.0)), + ]), + ), + ("size", obj(vec![("x", Kv::Float(50.0)), ("y", Kv::Float(20.0))])), + ], + ); + let background = record( + 2, + vec![ + ( + "transform", + obj(vec![ + ("m00", Kv::Float(1.0)), + ("m01", Kv::Float(0.0)), + ("m02", Kv::Float(0.0)), + ("m10", Kv::Float(0.0)), + ("m11", Kv::Float(1.0)), + ("m12", Kv::Float(0.0)), + ]), + ), + ("size", obj(vec![("x", Kv::Float(60.0)), ("y", Kv::Float(30.0))])), + ], + ); + let line_style = record(0, vec![]); + let text_style = record(1, vec![("textData", obj(vec![("characters", Kv::Str("budget label".into()))]))]); + let background_style = record(2, vec![("fillPaints", Kv::Array(vec![]))]); + obj(vec![ + ( + "nodeChanges", + Kv::Array(vec![obj(vec![ + ("guid", obj(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(123))])), + ("type", Kv::Enum("CONNECTOR")), + ("name", Kv::Str("budget connector".into())), + ("connectorType", Kv::Enum("MANUAL")), + ("connectorVersion", Kv::Float(1.0)), + ("connectorLineStyle", Kv::Enum("STRAIGHT")), + ("derivedImmutableFrameData", obj(vec![("version", Kv::Float(1.0)), ("overrides", Kv::Array(vec![line, text, background]))])), + ("nodeGenerationData", obj(vec![("overrides", Kv::Array(vec![line_style, text_style, background_style]))])), + ])]), + ), + ("blobs", Kv::Array(vec![obj(vec![("bytes", Kv::Bytes(path_blob()))])])), + ]) +} + +#[test] +fn connector_snapshot_respects_last_and_zero_child_slots() { + let message = message(); + let node = message.get("nodeChanges").unwrap().list().first().unwrap(); + let view = View::of(node); + let mut mapper = Mapper::new(&message); + mapper.count = MAX_NODES - 1; + let children = mapper.connector_snapshot_children(&view, "1:123"); + assert_eq!(children.len(), 1, "the final available slot preserves the saved vector"); + assert_eq!(children[0]["type"], "VECTOR"); + assert!(!children[0]["fillGeometry"].as_array().unwrap().is_empty(), "saved outline survives the budget limit"); + assert_eq!(children[0]["fillGeometry"][0]["path"], "M 0 0 L 20 10"); + assert_eq!(mapper.count, MAX_NODES); + assert!(mapper.warnings.iter().any(|warning| warning == &format!("the file has more than {MAX_NODES} layers; connector label was skipped"))); + + let mut mapper = Mapper::new(&message); + mapper.count = MAX_NODES; + let children = mapper.connector_snapshot_children(&view, "1:123"); + assert!(children.is_empty()); + assert_eq!(mapper.count, MAX_NODES, "the node budget is never exceeded"); + assert!(mapper.warnings.iter().any(|warning| warning.contains("connector snapshot children were skipped"))); +} + +#[test] +fn connector_snapshot_flattens_vector_at_depth_and_node_limits() { + let message = message(); + let mut mapper = Mapper::new(&message); + let depth_limited = mapper.layer("1:123", None, Affine::IDENTITY, None, None, MAX_DEPTH).expect("terminal connector"); + assert_eq!(depth_limited["type"], "VECTOR"); + assert!(!depth_limited["fillGeometry"].as_array().unwrap().is_empty()); + assert!(depth_limited.get("children").is_none()); + assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: depth limit prevents nested snapshot children")); + + let mut mapper = Mapper::new(&message); + mapper.count = MAX_NODES - 1; + let budget_limited = mapper.layer("1:123", None, Affine::IDENTITY, None, None, 0).expect("last allowed connector"); + assert_eq!(budget_limited["type"], "VECTOR"); + assert!(!budget_limited["fillGeometry"].as_array().unwrap().is_empty()); + assert!(budget_limited.get("children").is_none()); + assert_eq!(mapper.count, MAX_NODES); + assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: node budget prevents nested snapshot children")); +} diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 81457f8d..3c736854 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -12,6 +12,8 @@ //! tables, …) come in as the closest frames, text and vectors. Anything not understood is skipped //! with a warning; malformed input never panics. +#[cfg(test)] +mod connector_label_tests; mod export; #[cfg(test)] mod export_tests; From a00b2e0583ccf628a8d5c4c1fb0524efb87eb7ec Mon Sep 17 00:00:00 2001 From: Patrik Lermon Date: Fri, 16 Oct 2026 05:42:07 +0200 Subject: [PATCH 019/118] i18n(sv): translate the command and menu labels The [commands] and [menu] rows held their command id as the text ("align.bottom" = "align.bottom"), which the menus and the command palette would show as is. They now translate the English labels the code gives those ids (spec! and m! in crates/engine), e.g. "align.bottom" = "Justera nederkant". Also the interface strings that are new on upstream main (23bdebd). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/sv.toml | 949 +++++++++++++++++---------------- 1 file changed, 479 insertions(+), 470 deletions(-) diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 195c94af..4fdf723f 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -66,6 +66,7 @@ name = "Svenska" "Add variant with a new value" = "Lägg till variant med ett nytt värde" "Added" = "Tillagd" "Added the library \"{name}\"" = "Biblioteket ”{name}” har lagts till" +"Adding to {name}" = "Lägger till i {name}" "Adjust" = "Justera" "Adjustment presets" = "Justeringsförinställningar" "Advanced" = "Avancerat" @@ -407,6 +408,9 @@ name = "Svenska" "Draw" = "Rita" "Draw a frame" = "Rita en ram" "Drawing tools" = "Ritverktyg" +"Drop a .figcraft, .fig, .json, .svg or image file." = "Släpp en .figcraft-, .fig-, .json- eller .svg-fil eller en bildfil." +"Drop to import" = "Släpp för att importera" +"Drop to open" = "Släpp för att öppna" "Drop shadow" = "Släppskugga" "Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Dubbellicensierad under MIT OR Apache-2.0. Skapad av ArtCraft-teamet och gemenskapen." "Duo" = "Duo" @@ -900,6 +904,8 @@ name = "Svenska" "Open prototype preview" = "Öppna prototypförhandsgranskning" "Open variables ({count})" = "Öppna variabler ({count})" "Opened “{name}” in a new tab" = "Öppnade ”{name}” i en ny flik" +"Opening the file…" = "Öppnar filen…" +"Opening {n} files as new files" = { one = "Öppnar som en ny fil", other = "Öppnar {n} filer som nya filer" } "Open|comments" = "Öppna" "Options" = "Alternativ" "Orange" = "Orange" @@ -1320,6 +1326,9 @@ name = "Svenska" "Texts" = "Texter" "Texture" = "Textur" "The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Arbetsytan förhandsgranskar färger i sRGB; exporterade PNG-, JPG- och WebP-filer märks med Display P3." +"The destination document is unavailable. Nothing was imported." = "Måldokumentet är inte tillgängligt. Ingenting importerades." +"The destination document was closed. Nothing was imported." = "Måldokumentet stängdes. Ingenting importerades." +"The destination page was removed. Nothing was imported." = "Målsidan togs bort. Ingenting importerades." "The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Filen raderas från servern: ingen kan längre öppna eller ansluta till den därifrån, och detta kan inte ångras. Kopior som är öppna finns kvar hos dem som har dem." "The file now holds another library" = "Filen innehåller nu ett annat bibliotek" "The file now holds another library (\"{name}\")" = "Filen innehåller nu ett annat bibliotek (\"{name}\")" @@ -1598,127 +1607,127 @@ name = "Svenska" [commands] -"align.bottom" = "align.bottom" -"align.hcenter" = "align.hcenter" -"align.left" = "align.left" -"align.right" = "align.right" -"align.top" = "align.top" -"align.vcenter" = "align.vcenter" -"annotation.add" = "annotation.add" -"annotation.addCategory" = "annotation.addCategory" -"annotation.categories" = "annotation.categories" -"annotation.edit" = "annotation.edit" -"annotation.editCategory" = "annotation.editCategory" -"annotation.list" = "annotation.list" -"annotation.remove" = "annotation.remove" -"annotation.removeCategory" = "annotation.removeCategory" -"app.actions" = "app.actions" -"app.preferences" = "app.preferences" -"arrange.back" = "arrange.back" -"arrange.backward" = "arrange.backward" -"arrange.forward" = "arrange.forward" -"arrange.front" = "arrange.front" -"arrange.packH" = "arrange.packH" -"arrange.packV" = "arrange.packV" -"arrange.roundToPixels" = "arrange.roundToPixels" -"arrange.tidy" = "arrange.tidy" -"bool.exclude" = "bool.exclude" -"bool.flatten" = "bool.flatten" -"bool.intersect" = "bool.intersect" -"bool.subtract" = "bool.subtract" -"bool.union" = "bool.union" -"branch.archive" = "branch.archive" -"branch.create" = "branch.create" -"branch.list" = "branch.list" -"branch.merge" = "branch.merge" -"branch.open" = "branch.open" -"branch.review" = "branch.review" -"branch.updateFromMain" = "branch.updateFromMain" -"codeConnect.export" = "codeConnect.export" -"codeConnect.import" = "codeConnect.import" -"codeConnect.remove" = "codeConnect.remove" -"codeConnect.set" = "codeConnect.set" -"collab.cursor" = "collab.cursor" -"collab.cursorChat" = "collab.cursorChat" -"collab.diff" = "collab.diff" -"collab.files" = "collab.files" -"collab.follow" = "collab.follow" -"collab.forgetFile" = "collab.forgetFile" -"collab.host" = "collab.host" -"collab.join" = "collab.join" -"collab.leave" = "collab.leave" -"collab.manageFile" = "collab.manageFile" -"collab.openFile" = "collab.openFile" -"collab.peers" = "collab.peers" -"collab.publish" = "collab.publish" -"collab.recoverFile" = "collab.recoverFile" -"collab.registerLinks" = "collab.registerLinks" -"collab.removePerson" = "collab.removePerson" -"collab.report" = "collab.report" -"collab.serverInfo" = "collab.serverInfo" -"collab.share" = "collab.share" -"collab.spotlight" = "collab.spotlight" -"collab.stop" = "collab.stop" -"command.batch" = "command.batch" -"comment.add" = "comment.add" -"comment.delete" = "comment.delete" -"comment.edit" = "comment.edit" -"comment.link" = "comment.link" -"comment.list" = "comment.list" -"comment.markRead" = "comment.markRead" -"comment.move" = "comment.move" -"comment.next" = "comment.next" -"comment.open" = "comment.open" -"comment.people" = "comment.people" -"comment.react" = "comment.react" -"comment.reply" = "comment.reply" -"comment.resolve" = "comment.resolve" -"comment.zoom" = "comment.zoom" -"component.addProperty" = "component.addProperty" -"component.addVariant" = "component.addVariant" -"component.bindProperty" = "component.bindProperty" -"component.combineVariants" = "component.combineVariants" -"component.create" = "component.create" -"component.createMultiple" = "component.createMultiple" -"component.deleteProperty" = "component.deleteProperty" -"component.detach" = "component.detach" -"component.editProperty" = "component.editProperty" -"component.exposeInstance" = "component.exposeInstance" -"component.goToMain" = "component.goToMain" -"component.hideWhenPublishing" = "component.hideWhenPublishing" -"component.list" = "component.list" -"component.makeSlot" = "component.makeSlot" -"component.playground" = "component.playground" -"component.properties" = "component.properties" -"component.pushChanges" = "component.pushChanges" -"component.removeSlot" = "component.removeSlot" -"component.resetInstance" = "component.resetInstance" -"component.resetOverride" = "component.resetOverride" -"component.restoreMain" = "component.restoreMain" -"dev.compare" = "dev.compare" -"dev.focus" = "dev.focus" -"dev.resolveChange" = "dev.resolveChange" -"dev.unfocus" = "dev.unfocus" -"devResource.add" = "devResource.add" -"devResource.edit" = "devResource.edit" -"devResource.list" = "devResource.list" -"devResource.remove" = "devResource.remove" -"distribute.horizontal" = "distribute.horizontal" -"distribute.vertical" = "distribute.vertical" -"doc.home" = "doc.home" -"doc.list" = "doc.list" -"doc.nextTab" = "doc.nextTab" -"doc.previousTab" = "doc.previousTab" -"doc.switch" = "doc.switch" -"edit.copy" = "edit.copy" -"edit.copyAsCss" = "edit.copyAsCss" -"edit.copyAsPng" = "edit.copyAsPng" -"edit.copyAsSvg" = "edit.copyAsSvg" -"edit.copyAsText" = "edit.copyAsText" -"edit.copyLink" = "edit.copyLink" -"edit.copyProperties" = "edit.copyProperties" -"edit.cut" = "edit.cut" -"edit.delete" = "edit.delete" +"align.bottom" = "Justera nederkant" +"align.hcenter" = "Justera horisontella mittpunkter" +"align.left" = "Justera vänster" +"align.right" = "Justera höger" +"align.top" = "Justera överkant" +"align.vcenter" = "Justera vertikala mittpunkter" +"annotation.add" = "Lägg till anteckning" +"annotation.addCategory" = "Lägg till anteckningskategori" +"annotation.categories" = "Anteckningskategorier" +"annotation.edit" = "Redigera anteckning" +"annotation.editCategory" = "Redigera anteckningskategori" +"annotation.list" = "Lista anteckningar" +"annotation.remove" = "Ta bort anteckning" +"annotation.removeCategory" = "Radera anteckningskategori" +"app.actions" = "Åtgärder" +"app.preferences" = "Inställningar" +"arrange.back" = "Flytta längst bak" +"arrange.backward" = "Flytta bakåt" +"arrange.forward" = "Flytta framåt" +"arrange.front" = "Flytta längst fram" +"arrange.packH" = "Packa horisontellt" +"arrange.packV" = "Packa vertikalt" +"arrange.roundToPixels" = "Avrunda till pixlar" +"arrange.tidy" = "Snygga till" +"bool.exclude" = "Uteslut markering" +"bool.flatten" = "Platta till boolesk form" +"bool.intersect" = "Skärning av markering" +"bool.subtract" = "Subtrahera markering" +"bool.union" = "Förena markering" +"branch.archive" = "Arkivera gren" +"branch.create" = "Skapa gren" +"branch.list" = "Lista grenar" +"branch.merge" = "Sammanfoga gren" +"branch.open" = "Öppna gren" +"branch.review" = "Granska gren" +"branch.updateFromMain" = "Uppdatera från main" +"codeConnect.export" = "Exportera kodkopplingar" +"codeConnect.import" = "Importera kodkopplingar" +"codeConnect.remove" = "Koppla från koden" +"codeConnect.set" = "Koppla till kod" +"collab.cursor" = "Visa markör vid" +"collab.cursorChat" = "Markörchatt" +"collab.diff" = "Visa synkändringar" +"collab.files" = "Dina onlinefiler" +"collab.follow" = "Följ" +"collab.forgetFile" = "Glöm onlinefil" +"collab.host" = "Starta livesession" +"collab.join" = "Gå med i livesession" +"collab.leave" = "Lämna livesession" +"collab.manageFile" = "Hantera publicerad fil" +"collab.openFile" = "Öppna onlinefil" +"collab.peers" = "Personer i den här filen" +"collab.publish" = "Publicera på server" +"collab.recoverFile" = "Återställ onlinefil" +"collab.registerLinks" = "Öppna inbjudningslänkar i FigCraft" +"collab.removePerson" = "Ta bort från session" +"collab.report" = "Rapportera filen till serveroperatören" +"collab.serverInfo" = "Serverinfo" +"collab.share" = "Dela med en anslutningskod" +"collab.spotlight" = "Rikta strålkastaren mot mig" +"collab.stop" = "Stoppa livesession" +"command.batch" = "Kör flera kommandon" +"comment.add" = "Lägg till kommentar" +"comment.delete" = "Ta bort kommentar" +"comment.edit" = "Redigera kommentar" +"comment.link" = "Kopiera länk till kommentar" +"comment.list" = "Lista kommentarer" +"comment.markRead" = "Markera kommentar som läst" +"comment.move" = "Flytta kommentar" +"comment.next" = "Nästa kommentar" +"comment.open" = "Öppna kommentar" +"comment.people" = "Personer i kommentarer" +"comment.react" = "Reagera på kommentar" +"comment.reply" = "Svara på kommentar" +"comment.resolve" = "Lös kommentar" +"comment.zoom" = "Zooma till kommentarer" +"component.addProperty" = "Lägg till komponentegenskap" +"component.addVariant" = "Lägg till variant" +"component.bindProperty" = "Koppla lager till egenskap" +"component.combineVariants" = "Kombinera som varianter" +"component.create" = "Skapa komponent" +"component.createMultiple" = "Skapa flera komponenter" +"component.deleteProperty" = "Radera komponentegenskap" +"component.detach" = "Koppla bort instans" +"component.editProperty" = "Redigera komponentegenskap" +"component.exposeInstance" = "Exponera kapslad instans" +"component.goToMain" = "Gå till huvudkomponent" +"component.hideWhenPublishing" = "Dölj vid publicering" +"component.list" = "Lista komponenter" +"component.makeSlot" = "Gör till plats" +"component.playground" = "Komponenttestyta" +"component.properties" = "Komponentegenskaper" +"component.pushChanges" = "Skicka ändringar till huvudkomponenten" +"component.removeSlot" = "Ta bort plats" +"component.resetInstance" = "Återställ alla ändringar" +"component.resetOverride" = "Återställ åsidosättning" +"component.restoreMain" = "Återställ huvudkomponent" +"dev.compare" = "Jämför ändringar" +"dev.focus" = "Fokusvy" +"dev.resolveChange" = "Lös ändring" +"dev.unfocus" = "Lämna fokusvy" +"devResource.add" = "Lägg till utvecklarresurs" +"devResource.edit" = "Redigera utvecklarresurs" +"devResource.list" = "Lista utvecklarresurser" +"devResource.remove" = "Ta bort utvecklarresurs" +"distribute.horizontal" = "Fördela horisontellt avstånd" +"distribute.vertical" = "Fördela vertikalt avstånd" +"doc.home" = "Start" +"doc.list" = "Lista dokument" +"doc.nextTab" = "Nästa flik" +"doc.previousTab" = "Föregående flik" +"doc.switch" = "Byt dokument" +"edit.copy" = "Kopiera" +"edit.copyAsCss" = "Kopiera som CSS" +"edit.copyAsPng" = "Kopiera som PNG" +"edit.copyAsSvg" = "Kopiera som SVG" +"edit.copyAsText" = "Kopiera som text" +"edit.copyLink" = "Kopiera länk till markering" +"edit.copyProperties" = "Kopiera egenskaper" +"edit.cut" = "Klipp ut" +"edit.delete" = "Radera" "edit.duplicate" = "Duplicera" "edit.findReplace" = "Sök och ersätt" "edit.paste" = "Klistra in" @@ -1839,355 +1848,355 @@ name = "Svenska" "object.moveToPage" = "Flytta till sida" "object.opacityKeys" = "Nyckelbildrutor för opacitet" "object.rasterize" = "Rastrera" -"object.releaseMask" = "object.releaseMask" -"object.removeFill" = "object.removeFill" -"object.removeFrame" = "object.removeFrame" -"object.removeStroke" = "object.removeStroke" -"object.rename" = "object.rename" -"object.resizeToFit" = "object.resizeToFit" -"object.rotate180" = "object.rotate180" -"object.rotateLeft" = "object.rotateLeft" -"object.rotateRight" = "object.rotateRight" -"object.setThumbnail" = "object.setThumbnail" -"object.swapFillStroke" = "object.swapFillStroke" -"object.toggleLock" = "object.toggleLock" -"object.toggleVisible" = "object.toggleVisible" -"object.ungroup" = "object.ungroup" -"object.wrapInSection" = "object.wrapInSection" -"page.add" = "page.add" -"page.delete" = "page.delete" -"page.duplicate" = "page.duplicate" -"page.list" = "page.list" -"page.move" = "page.move" -"page.rename" = "page.rename" -"page.select" = "page.select" -"page.setBackground" = "page.setBackground" -"paint.documentColors" = "paint.documentColors" -"plugin.close" = "plugin.close" -"plugin.directory.install" = "plugin.directory.install" -"plugin.directory.list" = "plugin.directory.list" -"plugin.directory.publish" = "plugin.directory.publish" -"plugin.directory.unpublish" = "plugin.directory.unpublish" -"plugin.event" = "plugin.event" -"plugin.import" = "plugin.import" -"plugin.list" = "plugin.list" -"plugin.remove" = "plugin.remove" -"plugin.run" = "plugin.run" -"plugin.ui" = "plugin.ui" -"plugins.manage" = "plugins.manage" -"plugins.runLast" = "plugins.runLast" -"pref.autoSave" = "pref.autoSave" -"pref.highlightHover" = "pref.highlightHover" -"pref.keepTool" = "pref.keepTool" -"pref.language" = "pref.language" -"pref.nudge" = "pref.nudge" -"pref.numberKeysOpacity" = "pref.numberKeysOpacity" -"pref.publishServer" = "pref.publishServer" -"pref.reduceMotion" = "pref.reduceMotion" -"pref.renameDuplicates" = "pref.renameDuplicates" -"pref.showDimensions" = "pref.showDimensions" -"pref.userName" = "pref.userName" -"proto.action.add" = "proto.action.add" -"proto.action.move" = "proto.action.move" -"proto.action.remove" = "proto.action.remove" -"proto.action.set" = "proto.action.set" -"proto.closePreview" = "proto.closePreview" -"proto.conditional.addBlock" = "proto.conditional.addBlock" -"proto.conditional.removeBlock" = "proto.conditional.removeBlock" -"proto.conditional.setCondition" = "proto.conditional.setCondition" -"proto.evaluate" = "proto.evaluate" -"proto.flow.add" = "proto.flow.add" -"proto.flow.remove" = "proto.flow.remove" -"proto.flow.rename" = "proto.flow.rename" -"proto.interaction.add" = "proto.interaction.add" -"proto.interaction.clear" = "proto.interaction.clear" -"proto.interaction.remove" = "proto.interaction.remove" -"proto.interaction.set" = "proto.interaction.set" -"proto.present" = "proto.present" -"proto.presentOptions" = "proto.presentOptions" -"proto.preview" = "proto.preview" -"proto.settings" = "proto.settings" -"section.toggleContents" = "section.toggleContents" -"select.add" = "select.add" -"select.all" = "select.all" -"select.area" = "select.area" -"select.byName" = "select.byName" -"select.children" = "select.children" -"select.inverse" = "select.inverse" -"select.layersAt" = "select.layersAt" -"select.matching" = "select.matching" -"select.next" = "select.next" -"select.none" = "select.none" -"select.parent" = "select.parent" -"select.previous" = "select.previous" -"select.remove" = "select.remove" -"select.sameEffect" = "select.sameEffect" -"select.sameFill" = "select.sameFill" -"select.sameFont" = "select.sameFont" -"select.sameInstance" = "select.sameInstance" -"select.sameProperties" = "select.sameProperties" -"select.sameStroke" = "select.sameStroke" -"select.sameText" = "select.sameText" -"select.set" = "select.set" -"shape.arrow" = "shape.arrow" -"shape.ellipse" = "shape.ellipse" -"shape.line" = "shape.line" -"shape.polygon" = "shape.polygon" -"shape.rectangle" = "shape.rectangle" -"shape.star" = "shape.star" -"shapeBuilder.merge" = "shapeBuilder.merge" -"shapeBuilder.mode" = "shapeBuilder.mode" -"shapeBuilder.regions" = "shapeBuilder.regions" -"shapeBuilder.remove" = "shapeBuilder.remove" -"stroke.add" = "stroke.add" -"stroke.brush" = "stroke.brush" -"stroke.move" = "stroke.move" -"stroke.remove" = "stroke.remove" -"stroke.set" = "stroke.set" -"stroke.widthProfile" = "stroke.widthProfile" -"style.apply" = "style.apply" -"style.bind" = "style.bind" -"style.create" = "style.create" -"style.delete" = "style.delete" -"style.detach" = "style.detach" -"style.duplicate" = "style.duplicate" -"style.list" = "style.list" -"style.move" = "style.move" -"style.renameFolder" = "style.renameFolder" -"style.update" = "style.update" -"template.list" = "template.list" -"text.alignCenter" = "text.alignCenter" -"text.alignJustify" = "text.alignJustify" -"text.alignLeft" = "text.alignLeft" -"text.alignRight" = "text.alignRight" -"text.bold" = "text.bold" -"text.bulletList" = "text.bulletList" -"text.case" = "text.case" -"text.caseLower" = "text.caseLower" -"text.caseOriginal" = "text.caseOriginal" -"text.caseSmallCaps" = "text.caseSmallCaps" -"text.caseTitle" = "text.caseTitle" -"text.caseUpper" = "text.caseUpper" -"text.create" = "text.create" -"text.decoration" = "text.decoration" -"text.edit.begin" = "text.edit.begin" -"text.edit.caret" = "text.edit.caret" -"text.edit.end" = "text.edit.end" -"text.edit.insert" = "text.edit.insert" -"text.edit.key" = "text.edit.key" -"text.edit.preedit" = "text.edit.preedit" -"text.edit.selectLine" = "text.edit.selectLine" -"text.edit.selectWord" = "text.edit.selectWord" -"text.edit.selection" = "text.edit.selection" -"text.endPreview" = "text.endPreview" -"text.fonts" = "text.fonts" -"text.italic" = "text.italic" -"text.lineHeightDown" = "text.lineHeightDown" -"text.lineHeightUp" = "text.lineHeightUp" -"text.link" = "text.link" -"text.listIndent" = "text.listIndent" -"text.listOutdent" = "text.listOutdent" -"text.listStyle" = "text.listStyle" -"text.maxLines" = "text.maxLines" -"text.numberList" = "text.numberList" -"text.onPath" = "text.onPath" -"text.paragraphIndent" = "text.paragraphIndent" -"text.paragraphSpacing" = "text.paragraphSpacing" -"text.pathOptions" = "text.pathOptions" -"text.preview" = "text.preview" -"text.removeFromPath" = "text.removeFromPath" -"text.set" = "text.set" -"text.setFeatures" = "text.setFeatures" -"text.setVariations" = "text.setVariations" -"text.sizeDown" = "text.sizeDown" -"text.sizeUp" = "text.sizeUp" -"text.strikethrough" = "text.strikethrough" -"text.style" = "text.style" -"text.trackingDown" = "text.trackingDown" -"text.trackingUp" = "text.trackingUp" -"text.underline" = "text.underline" -"text.weightDown" = "text.weightDown" -"text.weightUp" = "text.weightUp" -"tool.arrow" = "tool.arrow" -"tool.comment" = "tool.comment" -"tool.ellipse" = "tool.ellipse" -"tool.frame" = "tool.frame" -"tool.hand" = "tool.hand" -"tool.line" = "tool.line" -"tool.move" = "tool.move" -"tool.pen" = "tool.pen" -"tool.pencil" = "tool.pencil" -"tool.pencilBrush" = "tool.pencilBrush" -"tool.place" = "tool.place" -"tool.polygon" = "tool.polygon" -"tool.rectangle" = "tool.rectangle" -"tool.scale" = "tool.scale" -"tool.scaleAnchor" = "tool.scaleAnchor" -"tool.section" = "tool.section" -"tool.set" = "tool.set" -"tool.shapeBuilder" = "tool.shapeBuilder" -"tool.slice" = "tool.slice" -"tool.star" = "tool.star" -"tool.text" = "tool.text" -"transform.move" = "transform.move" -"transform.moveTo" = "transform.moveTo" -"transform.nudge" = "transform.nudge" -"transform.nudgeSize" = "transform.nudgeSize" -"transform.resize" = "transform.resize" -"transform.rotate" = "transform.rotate" -"transform.rotationOrigin" = "transform.rotationOrigin" -"transform.scale" = "transform.scale" -"transform.setRotationOrigin" = "transform.setRotationOrigin" -"variable.addMode" = "variable.addMode" -"variable.bind" = "variable.bind" -"variable.create" = "variable.create" -"variable.createCollection" = "variable.createCollection" -"variable.delete" = "variable.delete" -"variable.deleteCollection" = "variable.deleteCollection" -"variable.duplicate" = "variable.duplicate" -"variable.export" = "variable.export" -"variable.import" = "variable.import" -"variable.list" = "variable.list" -"variable.moveMode" = "variable.moveMode" -"variable.removeMode" = "variable.removeMode" -"variable.renameCollection" = "variable.renameCollection" -"variable.renameMode" = "variable.renameMode" -"variable.resetOverride" = "variable.resetOverride" -"variable.set" = "variable.set" -"variable.setMode" = "variable.setMode" -"variable.update" = "variable.update" -"vector.create" = "vector.create" -"vector.deleteHeal" = "vector.deleteHeal" -"vector.edit.addVertex" = "vector.edit.addVertex" -"vector.edit.begin" = "vector.edit.begin" -"vector.edit.bend" = "vector.edit.bend" -"vector.edit.bounds" = "vector.edit.bounds" -"vector.edit.connect" = "vector.edit.connect" -"vector.edit.cornerRadius" = "vector.edit.cornerRadius" -"vector.edit.cut" = "vector.edit.cut" -"vector.edit.delete" = "vector.edit.delete" -"vector.edit.end" = "vector.edit.end" -"vector.edit.erase" = "vector.edit.erase" -"vector.edit.lasso" = "vector.edit.lasso" -"vector.edit.mirroring" = "vector.edit.mirroring" -"vector.edit.moveHandles" = "vector.edit.moveHandles" -"vector.edit.moveVertices" = "vector.edit.moveVertices" -"vector.edit.nudge" = "vector.edit.nudge" -"vector.edit.paint" = "vector.edit.paint" -"vector.edit.pen" = "vector.edit.pen" -"vector.edit.penEnd" = "vector.edit.penEnd" -"vector.edit.position" = "vector.edit.position" -"vector.edit.resize" = "vector.edit.resize" -"vector.edit.rotate" = "vector.edit.rotate" -"vector.edit.select" = "vector.edit.select" -"vector.edit.selectAll" = "vector.edit.selectAll" -"vector.edit.setHandle" = "vector.edit.setHandle" -"vector.edit.smooth" = "vector.edit.smooth" -"vector.edit.state" = "vector.edit.state" -"vector.edit.strokeCap" = "vector.edit.strokeCap" -"vector.edit.strokeJoin" = "vector.edit.strokeJoin" -"vector.edit.tool" = "vector.edit.tool" -"vector.edit.transform" = "vector.edit.transform" -"vector.edit.widthPoint" = "vector.edit.widthPoint" -"vector.flatten" = "vector.flatten" -"vector.join" = "vector.join" -"vector.offset" = "vector.offset" -"vector.outlineStroke" = "vector.outlineStroke" -"vector.reverse" = "vector.reverse" -"vector.simplify" = "vector.simplify" -"vector.smoothJoin" = "vector.smoothJoin" -"version.autosave" = "version.autosave" -"version.delete" = "version.delete" -"version.duplicate" = "version.duplicate" -"version.exitPreview" = "version.exitPreview" -"version.list" = "version.list" -"version.preview" = "version.preview" -"version.rename" = "version.rename" -"version.restore" = "version.restore" -"view.assets" = "view.assets" -"view.comments" = "view.comments" -"view.contextMenu" = "view.contextMenu" -"view.design" = "view.design" -"view.designPrototype" = "view.designPrototype" -"view.devMode" = "view.devMode" -"view.frameOutlines" = "view.frameOutlines" -"view.fullscreen" = "view.fullscreen" -"view.get" = "view.get" -"view.layers" = "view.layers" -"view.layoutGrids" = "view.layoutGrids" -"view.leftSidebar" = "view.leftSidebar" -"view.maskOutlines" = "view.maskOutlines" -"view.multiplayerCursors" = "view.multiplayerCursors" -"view.nextFrame" = "view.nextFrame" -"view.nextPage" = "view.nextPage" -"view.nextRegion" = "view.nextRegion" -"view.outlines" = "view.outlines" -"view.outlinesHiddenLayers" = "view.outlinesHiddenLayers" -"view.outlinesObjectBounds" = "view.outlinesObjectBounds" -"view.outlinesSelection" = "view.outlinesSelection" -"view.pan" = "view.pan" -"view.panelWidths" = "view.panelWidths" -"view.pixelGrid" = "view.pixelGrid" -"view.pixelPreview" = "view.pixelPreview" -"view.prevFrame" = "view.prevFrame" -"view.prevPage" = "view.prevPage" -"view.previousRegion" = "view.previousRegion" -"view.proofSrgb" = "view.proofSrgb" -"view.prototype" = "view.prototype" -"view.rulers" = "view.rulers" -"view.set" = "view.set" -"view.slices" = "view.slices" -"view.snapGeometry" = "view.snapGeometry" -"view.snapObjects" = "view.snapObjects" -"view.snapPixel" = "view.snapPixel" -"view.themeDark" = "view.themeDark" -"view.themeLight" = "view.themeLight" -"view.themeSystem" = "view.themeSystem" -"view.toggleUi" = "view.toggleUi" -"view.zoom100" = "view.zoom100" -"view.zoom200" = "view.zoom200" -"view.zoom50" = "view.zoom50" -"view.zoomAt" = "view.zoomAt" -"view.zoomFit" = "view.zoomFit" -"view.zoomIn" = "view.zoomIn" -"view.zoomOut" = "view.zoomOut" -"view.zoomSelection" = "view.zoomSelection" -"widget.click" = "widget.click" -"widget.insert" = "widget.insert" -"widget.list" = "widget.list" -"window.focus" = "window.focus" -"window.new" = "window.new" +"object.releaseMask" = "Frigör mask" +"object.removeFill" = "Ta bort fyllning" +"object.removeFrame" = "Ta bort ram" +"object.removeStroke" = "Ta bort kontur" +"object.rename" = "Byt namn" +"object.resizeToFit" = "Anpassa storlek" +"object.rotate180" = "Rotera 180°" +"object.rotateLeft" = "Rotera 90° åt vänster" +"object.rotateRight" = "Rotera 90° åt höger" +"object.setThumbnail" = "Ange som miniatyr" +"object.swapFillStroke" = "Byt plats på fyllning och kontur" +"object.toggleLock" = "Lås/lås upp markering" +"object.toggleVisible" = "Visa/dölj markering" +"object.ungroup" = "Dela upp markerade grupper" +"object.wrapInSection" = "Lägg i nytt avsnitt" +"page.add" = "Lägg till sida" +"page.delete" = "Radera sida" +"page.duplicate" = "Duplicera sida" +"page.list" = "Lista sidor" +"page.move" = "Flytta sida" +"page.rename" = "Byt namn på sida" +"page.select" = "Gå till sida" +"page.setBackground" = "Sidbakgrund" +"paint.documentColors" = "Dokumentfärger" +"plugin.close" = "Stäng plugin-panelen" +"plugin.directory.install" = "Installera plugin från katalogen" +"plugin.directory.list" = "Bläddra i plugin-katalogen" +"plugin.directory.publish" = "Publicera plugin i katalogen" +"plugin.directory.unpublish" = "Avpublicera plugin från katalogen" +"plugin.event" = "Använd kontroll i plugin-panelen" +"plugin.import" = "Importera plugin från manifest…" +"plugin.list" = "Lista plugin-program" +"plugin.remove" = "Ta bort plugin" +"plugin.run" = "Kör plugin" +"plugin.ui" = "Plugin-panel" +"plugins.manage" = "Hantera plugin-program…" +"plugins.runLast" = "Kör senaste plugin" +"pref.autoSave" = "Spara filer automatiskt" +"pref.highlightHover" = "Framhäv lager vid hovring" +"pref.keepTool" = "Behåll verktyget markerat efter användning" +"pref.language" = "Gränssnittsspråk" +"pref.nudge" = "Knuffavstånd" +"pref.numberKeysOpacity" = "Använd siffertangenterna för opacitet" +"pref.publishServer" = "Server för delning online" +"pref.reduceMotion" = "Minska rörelse" +"pref.renameDuplicates" = "Byt namn på duplicerade lager" +"pref.showDimensions" = "Visa dimensioner på objekt" +"pref.userName" = "Ditt namn i kommentarer" +"proto.action.add" = "Lägg till prototypåtgärd" +"proto.action.move" = "Ändra ordning på prototypåtgärd" +"proto.action.remove" = "Ta bort prototypåtgärd" +"proto.action.set" = "Redigera prototypåtgärd" +"proto.closePreview" = "Stäng prototypförhandsgranskning" +"proto.conditional.addBlock" = "Lägg till annars om / annars" +"proto.conditional.removeBlock" = "Ta bort om / annars om / annars" +"proto.conditional.setCondition" = "Redigera villkor" +"proto.evaluate" = "Beräkna prototyputtryck" +"proto.flow.add" = "Lägg till startpunkt för flöde" +"proto.flow.remove" = "Ta bort flöde" +"proto.flow.rename" = "Byt namn på flöde" +"proto.interaction.add" = "Lägg till interaktion" +"proto.interaction.clear" = "Ta bort alla interaktioner" +"proto.interaction.remove" = "Ta bort interaktion" +"proto.interaction.set" = "Redigera interaktion" +"proto.present" = "Presentera" +"proto.presentOptions" = "Presentationsalternativ" +"proto.preview" = "Öppna prototypförhandsgranskning" +"proto.settings" = "Prototypinställningar" +"section.toggleContents" = "Dölj/visa avsnittets innehåll" +"select.add" = "Lägg till i markering" +"select.all" = "Markera alla" +"select.area" = "Markera område" +"select.byName" = "Markera lager efter namn" +"select.children" = "Markera underordnade" +"select.inverse" = "Invertera markering" +"select.layersAt" = "Lager vid en punkt" +"select.matching" = "Markera matchande lager" +"select.next" = "Markera nästa syskon" +"select.none" = "Markera inga" +"select.parent" = "Markera överordnad" +"select.previous" = "Markera föregående syskon" +"select.remove" = "Ta bort från markering" +"select.sameEffect" = "Markera samma effekt" +"select.sameFill" = "Markera samma fyllning" +"select.sameFont" = "Markera samma teckensnitt" +"select.sameInstance" = "Markera samma instans" +"select.sameProperties" = "Markera samma egenskaper" +"select.sameStroke" = "Markera samma kontur" +"select.sameText" = "Markera samma textegenskaper" +"select.set" = "Markera" +"shape.arrow" = "Pil" +"shape.ellipse" = "Ellips" +"shape.line" = "Linje" +"shape.polygon" = "Polygon" +"shape.rectangle" = "Rektangel" +"shape.star" = "Stjärna" +"shapeBuilder.merge" = "Sammanfoga områden" +"shapeBuilder.mode" = "Formbyggarläge" +"shapeBuilder.regions" = "Formbyggarområden" +"shapeBuilder.remove" = "Ta bort områden" +"stroke.add" = "Lägg till kontur" +"stroke.brush" = "Pensel" +"stroke.move" = "Flytta kontur" +"stroke.remove" = "Ta bort kontur" +"stroke.set" = "Ange kontur" +"stroke.widthProfile" = "Breddprofil" +"style.apply" = "Använd stil" +"style.bind" = "Bind variabel i stil" +"style.create" = "Skapa stil" +"style.delete" = "Ta bort stil" +"style.detach" = "Koppla bort stil" +"style.duplicate" = "Duplicera stil" +"style.list" = "Lista stilar" +"style.move" = "Flytta stilar" +"style.renameFolder" = "Byt namn på stilmapp" +"style.update" = "Redigera stil" +"template.list" = "Lista mallar" +"text.alignCenter" = "Centrera" +"text.alignJustify" = "Marginaljustera" +"text.alignLeft" = "Justera vänster" +"text.alignRight" = "Justera höger" +"text.bold" = "Fet" +"text.bulletList" = "Punktlista" +"text.case" = "Skiftläge" +"text.caseLower" = "Gemener" +"text.caseOriginal" = "Ursprungligt skiftläge" +"text.caseSmallCaps" = "Kapitäler" +"text.caseTitle" = "Inledande versaler" +"text.caseUpper" = "Versaler" +"text.create" = "Text" +"text.decoration" = "Dekoration" +"text.edit.begin" = "Redigera text" +"text.edit.caret" = "Placera markören" +"text.edit.end" = "Sluta redigera text" +"text.edit.insert" = "Skriv text" +"text.edit.key" = "Texttangent" +"text.edit.preedit" = "Komponera text (IME)" +"text.edit.selectLine" = "Markera textrad" +"text.edit.selectWord" = "Markera ord" +"text.edit.selection" = "Textmarkering" +"text.endPreview" = "Avsluta förhandsgranskning av text" +"text.fonts" = "Lista teckensnitt" +"text.italic" = "Kursiv" +"text.lineHeightDown" = "Minska radhöjden" +"text.lineHeightUp" = "Öka radhöjden" +"text.link" = "Skapa länk" +"text.listIndent" = "Öka listindrag" +"text.listOutdent" = "Minska listindrag" +"text.listStyle" = "Liststil" +"text.maxLines" = "Max antal rader" +"text.numberList" = "Numrerad lista" +"text.onPath" = "Text på bana" +"text.paragraphIndent" = "Styckeindrag" +"text.paragraphSpacing" = "Styckeavstånd" +"text.pathOptions" = "Alternativ för textbana" +"text.preview" = "Förhandsgranska textändring" +"text.removeFromPath" = "Ta bort text från bana" +"text.set" = "Ange text" +"text.setFeatures" = "OpenType-funktioner" +"text.setVariations" = "Axlar för variabla teckensnitt" +"text.sizeDown" = "Minska teckenstorleken" +"text.sizeUp" = "Öka teckenstorleken" +"text.strikethrough" = "Genomstruken" +"text.style" = "Formatera textomfång" +"text.trackingDown" = "Minska teckenavståndet" +"text.trackingUp" = "Öka teckenavståndet" +"text.underline" = "Understrykning" +"text.weightDown" = "Minska teckenvikten" +"text.weightUp" = "Öka teckenvikten" +"tool.arrow" = "Pil" +"tool.comment" = "Kommentar" +"tool.ellipse" = "Ellips" +"tool.frame" = "Ram" +"tool.hand" = "Hand" +"tool.line" = "Linje" +"tool.move" = "Flytta" +"tool.pen" = "Penna" +"tool.pencil" = "Blyertspenna" +"tool.pencilBrush" = "Blyertspensel" +"tool.place" = "Placera med verktyget" +"tool.polygon" = "Polygon" +"tool.rectangle" = "Rektangel" +"tool.scale" = "Skala" +"tool.scaleAnchor" = "Skalningsankare" +"tool.section" = "Avsnitt" +"tool.set" = "Välj verktyg" +"tool.shapeBuilder" = "Formbyggare" +"tool.slice" = "Skiva" +"tool.star" = "Stjärna" +"tool.text" = "Text" +"transform.move" = "Flytta" +"transform.moveTo" = "Flytta till" +"transform.nudge" = "Knuffa" +"transform.nudgeSize" = "Ändra storlek genom att knuffa" +"transform.resize" = "Ändra storlek" +"transform.rotate" = "Rotera" +"transform.rotationOrigin" = "Flytta rotationspunkt" +"transform.scale" = "Skala" +"transform.setRotationOrigin" = "Ange rotationspunkt" +"variable.addMode" = "Lägg till läge" +"variable.bind" = "Bind variabel" +"variable.create" = "Skapa variabel" +"variable.createCollection" = "Skapa variabelsamling" +"variable.delete" = "Radera variabel" +"variable.deleteCollection" = "Radera samling" +"variable.duplicate" = "Duplicera variabel" +"variable.export" = "Exportera variabler" +"variable.import" = "Importera variabler" +"variable.list" = "Lista variabler" +"variable.moveMode" = "Flytta läge" +"variable.removeMode" = "Radera läge" +"variable.renameCollection" = "Byt namn på samling" +"variable.renameMode" = "Byt namn på läge" +"variable.resetOverride" = "Återställ åsidosatt variabel" +"variable.set" = "Ange variabelvärde" +"variable.setMode" = "Ange variabelläge" +"variable.update" = "Redigera variabel" +"vector.create" = "Vektor" +"vector.deleteHeal" = "Radera och läk markering" +"vector.edit.addVertex" = "Lägg till punkt" +"vector.edit.begin" = "Redigera vektor" +"vector.edit.bend" = "Böj segment" +"vector.edit.bounds" = "Markerade punkters gränser" +"vector.edit.connect" = "Koppla ihop punkter" +"vector.edit.cornerRadius" = "Hörnradie för punkt" +"vector.edit.cut" = "Dela bana" +"vector.edit.delete" = "Radera punkter" +"vector.edit.end" = "Klar med redigeringen" +"vector.edit.erase" = "Radera" +"vector.edit.lasso" = "Lassomarkering" +"vector.edit.mirroring" = "Handtagsspegling" +"vector.edit.moveHandles" = "Flytta handtag" +"vector.edit.moveVertices" = "Flytta punkter" +"vector.edit.nudge" = "Knuffa punkter" +"vector.edit.paint" = "Färghink" +"vector.edit.pen" = "Pennpunkt" +"vector.edit.penEnd" = "Avsluta pennbana" +"vector.edit.position" = "Punktposition" +"vector.edit.resize" = "Ändra storlek på punkter" +"vector.edit.rotate" = "Rotera punkter" +"vector.edit.select" = "Markera punkter" +"vector.edit.selectAll" = "Markera alla punkter" +"vector.edit.setHandle" = "Ange handtag" +"vector.edit.smooth" = "Gör punkter mjuka eller skarpa" +"vector.edit.state" = "Tillstånd för vektorredigering" +"vector.edit.strokeCap" = "Konturände för punkt" +"vector.edit.strokeJoin" = "Konturhörn för punkt" +"vector.edit.tool" = "Vektorredigeringsverktyg" +"vector.edit.transform" = "Transformera punkter" +"vector.edit.widthPoint" = "Breddpunkt" +"vector.flatten" = "Platta till" +"vector.join" = "Sammanfoga markering" +"vector.offset" = "Förskjut bana" +"vector.outlineStroke" = "Konvertera kontur till form" +"vector.reverse" = "Vänd banans riktning" +"vector.simplify" = "Förenkla vektor" +"vector.smoothJoin" = "Sammanfoga markering mjukt" +"version.autosave" = "Autospara version" +"version.delete" = "Radera version" +"version.duplicate" = "Duplicera version" +"version.exitPreview" = "Tillbaka till aktuell version" +"version.list" = "Lista versioner" +"version.preview" = "Visa version" +"version.rename" = "Byt namn på version" +"version.restore" = "Återställ version" +"view.assets" = "Panelen Tillgångar" +"view.comments" = "Kommentarer" +"view.contextMenu" = "Visa snabbmeny" +"view.design" = "Panelen Design" +"view.designPrototype" = "Växla mellan Design och Prototyp" +"view.devMode" = "Utvecklarläge" +"view.frameOutlines" = "Konturlinjer för ramar" +"view.fullscreen" = "Helskärm" +"view.get" = "Hämta vy" +"view.layers" = "Panelen Lager" +"view.layoutGrids" = "Layoutrutnät" +"view.leftSidebar" = "Visa/dölj vänster sidofält" +"view.maskOutlines" = "Konturlinjer för masker" +"view.multiplayerCursors" = "Samarbetsmarkörer" +"view.nextFrame" = "Zooma till nästa ram" +"view.nextPage" = "Nästa sida" +"view.nextRegion" = "Fokusera nästa region" +"view.outlines" = "Konturlinjer" +"view.outlinesHiddenLayers" = "Inkludera dolda lager i konturlinjer" +"view.outlinesObjectBounds" = "Inkludera objektgränser i konturlinjer" +"view.outlinesSelection" = "Konturlinjer för markering" +"view.pan" = "Panorera vyn" +"view.panelWidths" = "Återställ panelbredder" +"view.pixelGrid" = "Pixelrutnät" +"view.pixelPreview" = "Pixelförhandsgranskning" +"view.prevFrame" = "Zooma till föregående ram" +"view.prevPage" = "Föregående sida" +"view.previousRegion" = "Fokusera föregående region" +"view.proofSrgb" = "Skärmkorrektur i sRGB" +"view.prototype" = "Panelen Prototyp" +"view.rulers" = "Linjaler" +"view.set" = "Ange vy" +"view.slices" = "Skivor" +"view.snapGeometry" = "Fäst mot geometri" +"view.snapObjects" = "Fäst mot objekt" +"view.snapPixel" = "Fäst mot pixelrutnät" +"view.themeDark" = "Mörkt tema" +"view.themeLight" = "Ljust tema" +"view.themeSystem" = "Följ systemets tema" +"view.toggleUi" = "Visa/dölj gränssnitt" +"view.zoom100" = "Zooma till 100 %" +"view.zoom200" = "Zooma till 200 %" +"view.zoom50" = "Zooma till 50 %" +"view.zoomAt" = "Zoomverktyg" +"view.zoomFit" = "Zooma för att anpassa" +"view.zoomIn" = "Zooma in" +"view.zoomOut" = "Zooma ut" +"view.zoomSelection" = "Zooma till markering" +"widget.click" = "Klicka på widget" +"widget.insert" = "Infoga widget" +"widget.list" = "Lista widgetar" +"window.focus" = "Placera fönstret längst fram" +"window.new" = "Nytt fönster" [menu] -"bool.exclude" = "bool.exclude" -"bool.flatten" = "bool.flatten" -"bool.intersect" = "bool.intersect" -"bool.subtract" = "bool.subtract" -"bool.union" = "bool.union" -"file.duplicate" = "file.duplicate" -"file.import" = "file.import" -"file.rename" = "file.rename" -"libraries.open" = "libraries.open" -"select.sameEffect" = "select.sameEffect" -"select.sameFill" = "select.sameFill" -"select.sameFont" = "select.sameFont" -"select.sameInstance" = "select.sameInstance" -"select.sameProperties" = "select.sameProperties" -"select.sameStroke" = "select.sameStroke" -"select.sameText" = "select.sameText" -"text.alignCenter" = "text.alignCenter" -"text.alignJustify" = "text.alignJustify" -"text.alignLeft" = "text.alignLeft" -"text.alignRight" = "text.alignRight" -"view.assets" = "view.assets" -"view.design" = "view.design" -"view.layers" = "view.layers" -"view.outlines" = "view.outlines" -"view.outlinesHiddenLayers" = "view.outlinesHiddenLayers" -"view.outlinesObjectBounds" = "view.outlinesObjectBounds" -"view.outlinesSelection" = "view.outlinesSelection" -"view.prototype" = "view.prototype" -"view.themeDark" = "view.themeDark" -"view.themeLight" = "view.themeLight" -"view.themeSystem" = "view.themeSystem" +"bool.exclude" = "Uteslut" +"bool.flatten" = "Platta till" +"bool.intersect" = "Skärning" +"bool.subtract" = "Subtrahera" +"bool.union" = "Förena" +"file.duplicate" = "Duplicera" +"file.import" = "Importera från fil…" +"file.rename" = "Byt namn" +"libraries.open" = "Öppna bibliotek" +"select.sameEffect" = "Effekt" +"select.sameFill" = "Fyllning" +"select.sameFont" = "Teckensnitt" +"select.sameInstance" = "Instans" +"select.sameProperties" = "Egenskaper" +"select.sameStroke" = "Kontur" +"select.sameText" = "Textegenskaper" +"text.alignCenter" = "Centrera text" +"text.alignJustify" = "Marginaljustera text" +"text.alignLeft" = "Vänsterjustera text" +"text.alignRight" = "Högerjustera text" +"view.assets" = "Tillgångar" +"view.design" = "Design" +"view.layers" = "Lager" +"view.outlines" = "Visa konturlinjer" +"view.outlinesHiddenLayers" = "Inkludera dolda lager" +"view.outlinesObjectBounds" = "Inkludera objektgränser" +"view.outlinesSelection" = "Visa konturlinjer för markering" +"view.prototype" = "Prototyp" +"view.themeDark" = "Mörk" +"view.themeLight" = "Ljus" +"view.themeSystem" = "Följ systemet" From cc26c29217b125cb7222e91cd2fafd713513cfa8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 15 Oct 2026 23:57:45 -0400 Subject: [PATCH 020/118] ci: switch to shared nix workflow from craft-common-actions --- .github/workflows/nix.yml | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 18392587..01cb9cd6 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -18,16 +18,6 @@ concurrency: cancel-in-progress: true jobs: - eval: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - - name: nix flake check --no-build - run: | - if [ -f flake.nix ]; then - nix flake check --no-build --all-systems - else - echo "No flake.nix present in this repository; skipping check." - fi + nix: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/nix.yml@main From 167e06f6950033eb4c3d072624818de077ef9521 Mon Sep 17 00:00:00 2001 From: Zaxaerith Date: Fri, 16 Oct 2026 12:08:26 +0800 Subject: [PATCH 021/118] docs: correct example plugin count (#392) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0910bf9e..a072a06f 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control - **Runs in the browser:** the whole engine and UI compile to WebAssembly. - **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes. - **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). -- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). +- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). - **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md). - **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md). - **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session. From 107aa27d0a7407122b598df79f57af75d1ba00e3 Mon Sep 17 00:00:00 2001 From: Patrik Lermon Date: Fri, 16 Oct 2026 06:20:40 +0200 Subject: [PATCH 022/118] docs: Swedish now covers the command labels and menu wordings ROADMAP.md, docs/localization-parity.md and docs/translating.md said Swedish had 120 of the 588 command labels and menu wordings; with the previous commit it has all of them. Worded without counts, so the docs stay right as commands are added. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- docs/localization-parity.md | 4 ++-- docs/translating.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c3c12085..be9e9810 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -111,7 +111,7 @@ native speakers' reviews of the six language drafts need people. | Korean | ko | 100% | draft (needs native review) | | Vietnamese | vi | 0% | none | -Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese +Also shipped: Swedish (`sv`: every UI string, command label and menu wording; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 45a7a925..d3f69a9a 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -100,8 +100,8 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v | Korean | ko | 2,076 / 2,076 (100%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | | Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics | no | none | 3–5 | -Other languages: Swedish (`sv`): every UI string translated, and 120 of the 588 command labels and -menu wordings (the rest show in English); native review welcome. Figma ships none beyond the eight +Other languages: Swedish (`sv`): every UI string, command label and menu wording translated; native +review welcome. Figma ships none beyond the eight above. The layer is done. Per-language hours above are agent hours for a catalog of ~1,800 entries diff --git a/docs/translating.md b/docs/translating.md index d93c0255..3aa42d65 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -22,7 +22,7 @@ keeps Frame, Dev Mode and Smart animate as familiar tool names and uses « » qu was machine-drafted and reviewed by a native speaker. `es-419`), French (`fr`), German (`de`), Brazilian Portuguese (`pt-BR`), Japanese (`ja`), Korean (`ko`) and Simplified Chinese (`zh-Hans`), machine-drafted and waiting for native speakers' review, and -Swedish (`sv`), which translates every UI string and some command labels. +Swedish (`sv`), which translates every UI string, command label and menu wording. ## The catalogs From ab10e37d434ec09e8bcf0e11f62783439a522820 Mon Sep 17 00:00:00 2001 From: JimKnoxx Date: Wed, 14 Oct 2026 08:47:59 +0200 Subject: [PATCH 023/118] M1: paste layers copied in Figma Figma puts copied layers in the clipboard's HTML as a base64 .fig canvas between (figma) markers. edit.pasteFromFigma reads it with the .fig import, turns the layers into the session clipboard and pastes them as edit.paste does (selected frame, in place or view centre), with the import's warnings. The desktop app reads the clipboard's HTML (arboard) and Paste prefers a Figma copy; the browser's paste event carries plain text only. Tested on copies written by our own .fig export; not yet on real copies. Co-Authored-By: Claude Opus 5.5 --- apps/figcraft/src/services.rs | 3 + crates/engine/src/cmd/edit.rs | 78 ++++++++++++++++++--- crates/engine/src/cmd/file.rs | 15 ++-- crates/engine/src/figma_paste_tests.rs | 92 +++++++++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/ui-egui/locales/de.toml | 2 + crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 2 + crates/ui-egui/locales/fr.toml | 2 + crates/ui-egui/locales/ja.toml | 2 + crates/ui-egui/locales/ko.toml | 2 + crates/ui-egui/locales/pt-BR.toml | 2 + crates/ui-egui/locales/zh-Hans.toml | 2 + crates/ui-egui/src/figma_paste_tests.rs | 48 +++++++++++++ crates/ui-egui/src/lib.rs | 5 ++ crates/ui-egui/src/menus.rs | 17 +++-- docs/file-format-parity.md | 5 +- 17 files changed, 259 insertions(+), 21 deletions(-) create mode 100644 crates/engine/src/figma_paste_tests.rs create mode 100644 crates/ui-egui/src/figma_paste_tests.rs diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index bd8a4204..334f007b 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -241,6 +241,9 @@ impl Services for Desktop { fn clipboard_get_text(&mut self) -> Option { self.clipboard.as_mut()?.get_text().ok() } + fn clipboard_get_html(&mut self) -> Option { + self.clipboard.as_mut()?.get().html().ok() + } fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) { if let Some(c) = self.clipboard.as_mut() { let _ = c.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(rgba) }); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2c767b93..27d9652e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -25,6 +25,13 @@ pub fn specs() -> Vec { spec!("edit.copy", "Copy", "{ids?}", false, copy), spec!("edit.cut", "Cut", "{ids?}", true, cut), spec!("edit.paste", "Paste", "{parent?, x?, y? (page coordinates of the pasted top-left)}", true, paste), + spec!( + "edit.pasteFromFigma", + "Paste from Figma", + "{html (the clipboard's HTML) | data (base64 of the copied buffer), parent?, x?, y?} layers copied in Figma, read like a .fig file (best effort, with `warnings`), pasted as edit.paste pastes", + true, + paste_from_figma + ), spec!("edit.pasteOver", "Paste over selection", "{}", true, paste_over), spec!("edit.pasteReplace", "Paste to replace", "{ids?}", true, paste_replace), spec!("edit.copyProperties", "Copy properties", "{id?}", false, copy_props), @@ -498,7 +505,20 @@ fn bump_name(n: &str) -> String { /// The copied form of the selection. pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> { let ds = s.ds()?; - let doc = &ds.doc; + let (nodes, images, bounds) = copied(&ds.doc, ids); + let (source, source_path, uid) = (ds.doc.clone(), ds.path.clone(), ds.uid); + s.clipboard.source = Some(source); + s.clipboard.source_path = source_path; + s.clipboard.source_doc = uid; + s.clipboard.nodes = nodes; + s.clipboard.images = images; + s.clipboard.bounds = bounds; + Ok(()) +} + +type Copied = (Vec<(NodeId, Vec)>, BTreeMap, Option); + +fn copied(doc: &Document, ids: &[NodeId]) -> Copied { let ids = z_sorted(doc, &roots(doc, ids)); let mut nodes = Vec::new(); let mut images = BTreeMap::new(); @@ -521,16 +541,52 @@ pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> { nodes.push((*i, sub)); } let bounds = doc.bounds_of(&ids); - let uid = ds.uid; - let source = Some(ds.doc.clone()); - let source_path = ds.path.clone(); - s.clipboard.source = source; - s.clipboard.source_path = source_path; - s.clipboard.nodes = nodes; - s.clipboard.images = images; - s.clipboard.bounds = bounds; - s.clipboard.source_doc = uid; - Ok(()) + (nodes, images, bounds) +} + +const MAX_FIGMA_CLIP: usize = 512 * 1024 * 1024; + +/// Figma puts the copied layers in the HTML as `(figma)(/figma)`. This gives those bytes. +pub fn figma_clipboard_buffer(html: &str) -> Option> { + use base64::Engine as _; + const OPEN: &str = "(figma)"; + let start = html.find(OPEN)? + OPEN.len(); + let len = html.get(start..)?.find("(/figma)")?; + let text = html.get(start..start + len)?; + if text.len() > MAX_FIGMA_CLIP { + return None; + } + let b64: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect(); + let bytes = + base64::engine::general_purpose::STANDARD.decode(&b64).or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&b64)).ok()?; + figcraft_format::fig::is_fig(&bytes).then_some(bytes) +} + +fn paste_from_figma(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "edit.pasteFromFigma"; + let bytes = match (string(p, "html"), string(p, "data")) { + (Some(html), _) => figma_clipboard_buffer(&html).ok_or_else(|| bad(CMD, "the HTML holds no layers copied in Figma"))?, + (None, Some(d)) => { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| bad(CMD, e.to_string()))? + } + (None, None) => return Err(bad(CMD, "pass `html` or `data`")), + }; + let other = super::file::load_fig(&bytes, "Pasted from Figma").ok_or_else(|| bad(CMD, "the data is not layers copied in Figma"))??; + let doc = std::sync::Arc::new(other.doc); + let tops: Vec = doc.pages.iter().filter_map(|pg| doc.node(*pg)).flat_map(|pg| pg.children.iter().copied()).collect(); + if tops.is_empty() { + return Err(EngineError::Disabled(CMD.into(), "the copied layers came in empty".into())); + } + let (nodes, images, bounds) = copied(&doc, &tops); + let props = s.clipboard.props.take(); + // No open file has id 0. So paste treats this copy as a copy from a different file. + s.clipboard = crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None }; + let mut out = paste(s, p)?; + if !other.issues.is_empty() { + out["warnings"] = json!(other.issues); + } + Ok(out) } fn copy(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 5e27c572..fc78697c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -506,10 +506,8 @@ fn load_with( Ok((doc.into(), false)) } _ => { - if let Some(r) = figcraft_format::fig::try_import(bytes, &file_stem(name_hint)) { - let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?; - link_instances(&mut doc); - return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true)); + if let Some(r) = load_fig(bytes, &file_stem(name_hint)) { + return Ok((r?, true)); } if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) { let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?; @@ -525,6 +523,15 @@ fn load_with( } } +/// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try. +pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option> { + let r = figcraft_format::fig::try_import(bytes, name)?; + Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { + link_instances(&mut doc); + figcraft_format::Loaded { issues: warnings, ..doc.into() } + })) +} + /// Link imported instances to their mains (layer sources, overrides found by comparing values), /// innermost first so outer instances settle last. fn link_instances(doc: &mut Document) { diff --git a/crates/engine/src/figma_paste_tests.rs b/crates/engine/src/figma_paste_tests.rs new file mode 100644 index 00000000..7468f3ac --- /dev/null +++ b/crates/engine/src/figma_paste_tests.rs @@ -0,0 +1,92 @@ +use base64::Engine as _; +use serde_json::json; + +use super::*; +use crate::cmd::edit::figma_clipboard_buffer; + +const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD; + +fn id(v: &Value) -> NodeId { + params::id_of(&v["id"]).unwrap() +} + +fn copied_file() -> Vec { + let mut s = Session::new(); + let frame = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})).unwrap()); + s.execute("node.create", &json!({"type": "rectangle", "x": 10, "y": 10, "width": 40, "height": 20, "parent": frame, "name": "Badge"})).unwrap(); + s.execute("node.create", &json!({"type": "ellipse", "x": 300, "y": 0, "width": 50, "height": 50, "name": "Dot"})).unwrap(); + let r = s.execute("file.export", &json!({"format": "fig"})).unwrap(); + B64.decode(r["data"].as_str().unwrap()).unwrap() +} + +fn clipboard_html(buffer: &[u8]) -> String { + let meta = B64.encode(br#"{"dataType":"scene"}"#); + let mut wrapped = B64.encode(buffer); + wrapped.insert(8, '\n'); + format!( + r#""# + ) +} + +fn names(s: &Session, ids: &[NodeId]) -> Vec { + let doc = s.doc().unwrap(); + ids.iter().map(|i| doc.node(*i).unwrap().name.clone()).collect() +} + +#[test] +fn buffer_is_found_in_the_html() { + let file = copied_file(); + assert_eq!(figma_clipboard_buffer(&clipboard_html(&file)), Some(file)); + assert_eq!(figma_clipboard_buffer("

plain

"), None); + assert_eq!(figma_clipboard_buffer("(figma)not base64!(/figma)"), None); + assert_eq!(figma_clipboard_buffer(&format!("(figma){}(/figma)", B64.encode(b"hello"))), None); + assert_eq!(figma_clipboard_buffer("(figma)ZmlnLQ"), None); +} + +#[test] +fn layers_paste_into_the_page_and_undo() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let r = s.execute("edit.pasteFromFigma", &json!({"html": clipboard_html(&copied_file())})).unwrap(); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect(); + assert_eq!(names(&s, &ids), ["Card", "Dot"]); + assert_eq!(s.selection(), ids); + let doc = s.doc().unwrap(); + assert!(ids.iter().all(|i| doc.node(*i).unwrap().parent == Some(page))); + let card = doc.node(ids[0]).unwrap(); + assert_eq!(names(&s, &card.children), ["Badge"]); + assert_eq!((card.width, card.height), (200.0, 100.0)); + assert!(s.undo().unwrap()); + assert!(ids.iter().all(|i| !s.doc().unwrap().contains(*i))); +} + +#[test] +fn a_second_paste_repeats_the_figma_layers() { + let mut s = Session::new(); + s.execute("edit.pasteFromFigma", &json!({"data": B64.encode(copied_file())})).unwrap(); + let r = s.execute("edit.paste", &json!({})).unwrap(); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect(); + assert_eq!(names(&s, &ids), ["Card", "Dot"]); +} + +#[test] +fn pastes_into_the_selected_frame() { + let mut s = Session::new(); + let host = id(&s.execute("frame.create", &json!({"x": 1000, "y": 0, "width": 800, "height": 600})).unwrap()); + s.execute("select.set", &json!({"ids": [host]})).unwrap(); + let r = s.execute("edit.pasteFromFigma", &json!({"html": clipboard_html(&copied_file())})).unwrap(); + let first = params::id_of(&r["ids"][0]).unwrap(); + assert_eq!(s.doc().unwrap().node(first).unwrap().parent, Some(host)); +} + +#[test] +fn other_content_is_refused_without_changes() { + let mut s = Session::new(); + let before = s.doc().unwrap().nodes().count(); + assert!(s.execute("edit.pasteFromFigma", &json!({})).is_err()); + assert!(s.execute("edit.pasteFromFigma", &json!({"html": "hi"})).is_err()); + assert!(s.execute("edit.pasteFromFigma", &json!({"data": B64.encode(b"PK not a zip")})).is_err()); + assert!(s.execute("edit.pasteFromFigma", &json!({"data": B64.encode(b"fig-kiwi\x00\x00\x00\x00junk")})).is_err()); + assert_eq!(s.doc().unwrap().nodes().count(), before); + assert!(s.clipboard.nodes.is_empty()); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..908c952a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1528,6 +1528,8 @@ mod fig_export_tests; #[cfg(test)] mod fig_import_tests; #[cfg(test)] +mod figma_paste_tests; +#[cfg(test)] mod frame_guides_tests; #[cfg(test)] mod frame_label_tests; diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index cd1e3482..e4749235 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -899,6 +899,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "Link einer Teambibliothek einfügen (ein Ansichtslink)" "Paste here" = "Hier einfügen" "Paste to replace" = "Zum Ersetzen einfügen" +"Pasted from Figma; {n} kinds of content were left out" = { one = "Aus Figma eingefügt; {n} Art von Inhalt wurde ausgelassen", other = "Aus Figma eingefügt; {n} Arten von Inhalt wurden ausgelassen" } "Pattern" = "Muster" "Pattern alignment" = "Musterausrichtung" "Pattern scale" = "Musterskalierung" @@ -1654,6 +1655,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplizieren" "edit.findReplace" = "Suchen und ersetzen" "edit.paste" = "Einfügen" +"edit.pasteFromFigma" = "Aus Figma einfügen" "edit.pasteOver" = "Über Auswahl einfügen" "edit.pasteProperties" = "Eigenschaften einfügen" "edit.pasteReplace" = "Zum Ersetzen einfügen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 3c127fc6..f0865ea4 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -943,6 +943,7 @@ name = "English" "Paste a team library link (a view link)" = "Paste a team library link (a view link)" "Paste here" = "Paste here" "Paste to replace" = "Paste to replace" +"Pasted from Figma; {n} kinds of content were left out" = { one = "Pasted from Figma; {n} kind of content was left out", other = "Pasted from Figma; {n} kinds of content were left out" } "Pattern" = "Pattern" "Pattern alignment" = "Pattern alignment" "Pattern scale" = "Pattern scale" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 901283f1..3a07c3ab 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -944,6 +944,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "Pega el enlace de una biblioteca de equipo (de lectura)" "Paste here" = "Pegar aquí" "Paste to replace" = "Pegar para reemplazar" +"Pasted from Figma; {n} kinds of content were left out" = { one = "Pegado desde Figma; se omitió {n} tipo de contenido", other = "Pegado desde Figma; se omitieron {n} tipos de contenido" } "Pattern" = "Patrón" "Pattern alignment" = "Alineación del patrón" "Pattern scale" = "Escala del patrón" @@ -1731,6 +1732,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplicar" "edit.findReplace" = "Buscar y reemplazar" "edit.paste" = "Pegar" +"edit.pasteFromFigma" = "Pegar desde Figma" "edit.pasteOver" = "Pegar sobre la selección" "edit.pasteProperties" = "Pegar propiedades" "edit.pasteReplace" = "Pegar para reemplazar" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 7fafd3c9..2f5ae5b9 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -893,6 +893,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "Collez le lien d’une bibliothèque d’équipe (un lien de consultation)" "Paste here" = "Coller ici" "Paste to replace" = "Coller pour remplacer" +"Pasted from Figma; {n} kinds of content were left out" = { one = "Collé depuis Figma ; {n} type de contenu a été omis", other = "Collé depuis Figma ; {n} types de contenu ont été omis" } "Pattern" = "Motif" "Pattern alignment" = "Alignement du motif" "Pattern scale" = "Échelle du motif" @@ -1645,6 +1646,7 @@ status = "draft — needs native review" "edit.duplicate" = "Dupliquer" "edit.findReplace" = "Rechercher et remplacer" "edit.paste" = "Coller" +"edit.pasteFromFigma" = "Coller depuis Figma" "edit.pasteOver" = "Coller par-dessus la sélection" "edit.pasteProperties" = "Coller les propriétés" "edit.pasteReplace" = "Coller pour remplacer" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 4d4d6d34..493bf999 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -939,6 +939,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "チームライブラリのリンク(閲覧リンク)を貼り付け" "Paste here" = "ここに貼り付け" "Paste to replace" = "貼り付けて置換" +"Pasted from Figma; {n} kinds of content were left out" = { other = "Figma から貼り付け済み。{n} 種類のコンテンツは含まれていない" } "Pattern" = "パターン" "Pattern alignment" = "パターンの配置" "Pattern scale" = "パターンの拡大縮小" @@ -1723,6 +1724,7 @@ status = "draft — needs native review" "edit.duplicate" = "複製" "edit.findReplace" = "検索と置換" "edit.paste" = "貼り付け" +"edit.pasteFromFigma" = "Figma から貼り付け" "edit.pasteOver" = "選択範囲の上に貼り付け" "edit.pasteProperties" = "プロパティを貼り付け" "edit.pasteReplace" = "貼り付けて置換" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index ec732905..253cc54e 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -893,6 +893,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "팀 라이브러리 링크(보기 링크) 붙여 넣기" "Paste here" = "여기에 붙여 넣기" "Paste to replace" = "붙여 넣어 바꾸기" +"Pasted from Figma; {n} kinds of content were left out" = { other = "Figma에서 붙여 넣음. 콘텐츠 {n}종은 제외됨" } "Pattern" = "패턴" "Pattern alignment" = "패턴 정렬" "Pattern scale" = "패턴 배율" @@ -1645,6 +1646,7 @@ status = "draft — needs native review" "edit.duplicate" = "복제" "edit.findReplace" = "찾기 및 바꾸기" "edit.paste" = "붙여 넣기" +"edit.pasteFromFigma" = "Figma에서 붙여 넣기" "edit.pasteOver" = "선택 항목 위에 붙여 넣기" "edit.pasteProperties" = "속성 붙여 넣기" "edit.pasteReplace" = "붙여 넣어 바꾸기" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index c2189de8..12e7c0b2 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -892,6 +892,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "Cole o link de uma biblioteca de equipe (um link de visualização)" "Paste here" = "Colar aqui" "Paste to replace" = "Colar para substituir" +"Pasted from Figma; {n} kinds of content were left out" = { one = "Colado do Figma; {n} tipo de conteúdo ficou de fora", other = "Colado do Figma; {n} tipos de conteúdo ficaram de fora" } "Pattern" = "Padrão" "Pattern alignment" = "Alinhamento do padrão" "Pattern scale" = "Escala do padrão" @@ -1644,6 +1645,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplicar" "edit.findReplace" = "Localizar e substituir" "edit.paste" = "Colar" +"edit.pasteFromFigma" = "Colar do Figma" "edit.pasteOver" = "Colar sobre a seleção" "edit.pasteProperties" = "Colar propriedades" "edit.pasteReplace" = "Colar para substituir" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index af4cf653..91c251ec 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -894,6 +894,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "粘贴团队库链接(查看链接)" "Paste here" = "粘贴到此处" "Paste to replace" = "粘贴以替换" +"Pasted from Figma; {n} kinds of content were left out" = { other = "已从 Figma 粘贴;有 {n} 类内容未包含" } "Pattern" = "图案" "Pattern alignment" = "图案对齐" "Pattern scale" = "图案缩放" @@ -1646,6 +1647,7 @@ status = "draft — needs native review" "edit.duplicate" = "创建副本" "edit.findReplace" = "查找和替换" "edit.paste" = "粘贴" +"edit.pasteFromFigma" = "从 Figma 粘贴" "edit.pasteOver" = "粘贴到所选项上方" "edit.pasteProperties" = "粘贴属性" "edit.pasteReplace" = "粘贴以替换" diff --git a/crates/ui-egui/src/figma_paste_tests.rs b/crates/ui-egui/src/figma_paste_tests.rs new file mode 100644 index 00000000..6147ba5e --- /dev/null +++ b/crates/ui-egui/src/figma_paste_tests.rs @@ -0,0 +1,48 @@ +use figcraft_engine::Session; +use serde_json::json; + +use crate::{FigApp, Services}; + +struct Clip { + html: String, + text: Option, +} + +impl Services for Clip { + fn clipboard_get_html(&mut self) -> Option { + Some(self.html.clone()) + } + fn clipboard_get_text(&mut self) -> Option { + self.text.clone() + } +} + +fn figma_html() -> String { + let mut s = Session::new(); + s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 40, "height": 20, "name": "From Figma"})).unwrap(); + let r = s.execute("file.export", &json!({"format": "fig"})).unwrap(); + format!(r#""#, r["data"].as_str().unwrap()) +} + +fn selected_names(app: &FigApp) -> Vec { + let doc = app.session.doc().unwrap(); + app.session.selection().iter().map(|i| doc.node(*i).unwrap().name.clone()).collect() +} + +#[test] +fn paste_takes_layers_copied_in_figma() { + let html = figma_html(); + let mut app = FigApp::new(Session::new(), Box::new(Clip { html, text: Some(String::new()) })); + crate::menus::paste(&mut app, None); + assert_eq!(selected_names(&app), ["From Figma"]); +} + +#[test] +fn html_from_other_apps_pastes_as_before() { + let clip = Clip { html: "Hello".into(), text: Some("Hello".into()) }; + let mut app = FigApp::new(Session::new(), Box::new(clip)); + crate::menus::paste(&mut app, None); + let doc = app.session.doc().unwrap(); + let id = app.session.selection()[0]; + assert_eq!(doc.node(id).unwrap().kind.text().unwrap().characters, "Hello"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20a8a77c..04fe3861 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -94,6 +94,8 @@ mod export_preview_tests; #[cfg(test)] mod field_tests; #[cfg(test)] +mod figma_paste_tests; +#[cfg(test)] mod frame_label_tests; #[cfg(test)] mod gap_handle_tests; @@ -226,6 +228,9 @@ pub trait Services { fn clipboard_get_text(&mut self) -> Option { None } + fn clipboard_get_html(&mut self) -> Option { + None + } fn clipboard_set_png(&mut self, _w: u32, _h: u32, _rgba: &[u8]) {} /// An image on the system clipboard as (width, height, straight RGBA). fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec)> { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b9d3299a..60adf027 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -7,7 +7,7 @@ use serde_json::json; use crate::FigApp; use crate::a11y; -use crate::i18n::{self, tr, tr_noop, trf}; +use crate::i18n::{self, tr, tr_noop, trf, trn}; use crate::icons::{self, Icon}; use crate::theme::{ACCENT, Tokens, regular}; use crate::toolbar::pretty_shortcut; @@ -30,10 +30,19 @@ pub const UI_COMMANDS: &[&str] = &[ ]; /// Run a menu item: some open dialogs or use platform services, the rest go to the engine. -/// Paste: the system clipboard wins when it holds something newer than our own copy (an image, SVG -/// or text from another app); otherwise our copied layers. `event_text` is the text the platform -/// handed over with the paste event (the browser has no other way to read it). +/// Paste: the system clipboard wins when it holds something newer than our own copy (layers copied +/// in Figma, an image, SVG or text from another app); otherwise our copied layers. `event_text` is +/// the text the platform handed over with the paste event (the browser has no other way to read it). pub fn paste(app: &mut FigApp, event_text: Option<&str>) { + if let Some(html) = app.services.clipboard_get_html().filter(|h| h.contains("(figma)")) { + if let Some(r) = app.run("edit.pasteFromFigma", json!({"html": html})) { + let left_out = r["warnings"].as_array().map_or(0, Vec::len); + if left_out > 0 { + app.toast(trn("Pasted from Figma; {n} kinds of content were left out", left_out, &[])); + } + } + return; + } let sys_text = app.services.clipboard_get_text().or_else(|| event_text.map(str::to_string)).filter(|t| !t.is_empty()); let ours = sys_text.is_some() && sys_text == app.ui.last_copy; let (cx, cy) = app.session.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0)); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d8e51711..63f281f6 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (paste from Figma (desktop); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -36,7 +36,7 @@ and prototype interactions are still unchecked there. | BMP | — | yes | | — | | MP4, MOV, WebM (video fills, paid plans) | yes | **no** | shortcut: decoding H.264/HEVC/VP9 needs non-Rust libraries (FFmpeg or each platform's media framework); presenter playback would reuse the animated GIF clock | 4–8 | | Images over 4096 px | downscaled to 4096 | kept | | — | -| Pasted content from other apps | images, SVG, text, Figma clipboard | images, SVG, text (newest clipboard content wins) | no Figma clipboard (it carries Figma's own binary) | with `.fig` | +| Pasted content from other apps | images, SVG, text, Figma clipboard | images, SVG, text, **layers copied in Figma** (desktop; best effort) (newest clipboard content wins) | layers copied in Figma arrive as a `.fig` canvas in the clipboard's HTML; `edit.pasteFromFigma` reads it with the `.fig` import and pastes it as Paste does (into the selected frame, in place or in the view's centre), with `warnings`. Image fills come without their pixels (the clipboard doesn't carry them). Not in the browser (its paste event carries plain text only). Tested on copies written by our own `.fig` export, not yet on real Figma copies | with `.fig` | ## Export @@ -162,6 +162,7 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Paste from Figma (desktop): layers copied in Figma paste as layers. Paste reads the clipboard's HTML; its `.fig` canvas goes through the `.fig` import and lands as Paste places our own layers (`edit.pasteFromFigma`, with `warnings`). Tested on copies written by our own `.fig` export only. Numbers unchanged | | 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. | | 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Opened `.fig` and file JSON documents link nested instances of variants (and any nested instance under a name of its own) to their main's layers; they stayed unlinked, so a second export lost what they changed. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | | 2026-10-12 | minor | `.fig` export made to open elsewhere (gap 1): the document and its pages say they are visible and opaque (left out, nothing was drawn), the schema in the file uses the format's own names, kinds, field ids and enum values, rectangles are rounded rectangles with four radii, frames and shapes carry their fill outline and plain strokes their stroke outline, text carries its layout settings, font styles use their standard names, line heights that are a share of the font size are raw multipliers, masks keep their type (read back too); the import reads per-side stroke weights whose zero sides are left out. Written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers unchanged | From 657a8f4df62a35e647198ac43b8dcca820fcba19 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 09:16:10 +0200 Subject: [PATCH 024/118] M1: translate the vector style import notes --- crates/ui-egui/locales/de.toml | 3 +++ crates/ui-egui/locales/en.toml | 3 +++ crates/ui-egui/locales/es.toml | 3 +++ crates/ui-egui/src/import_report.rs | 3 +++ crates/ui-egui/src/import_report_tests.rs | 13 +++++++++++++ 5 files changed, 25 insertions(+) diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index c7bd13f7..520d4ba6 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1499,7 +1499,10 @@ status = "draft — needs native review" "set-variable actions with values that don't fit the variable were skipped" = "Aktionen „Variable festlegen“ mit Werten, die nicht zur Variable passen, wurden übersprungen" "set-variable actions without a value were skipped" = "Aktionen „Variable festlegen“ ohne Wert wurden übersprungen" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "Aktionen „Variablenmodus festlegen“ für Sammlungen oder Modi, die nicht im Variablen-JSON sind, wurden übersprungen" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "einige Vektorpunkte verweisen auf Punkteinstellungen, die in der Datei fehlen; sie verwenden die Konturecken der Ebene und einfache Ecken" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "einige Vektorsegmente verweisen auf Stileinstellungen, die in der Datei fehlen; sie verwenden die Kontur der Ebene" "stars and polygons without point counts or geometry use the default point count" = "Sterne und Polygone ohne Punktanzahl oder Geometrie verwenden die Standard-Punktanzahl" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "Stileinstellungen einiger Vektorsegmente werden nicht unterstützt; diese Segmente verwenden die Kontur der Ebene" "text style overrides refer to missing table entries" = "Textstil-Überschreibungen verweisen auf fehlende Tabelleneinträge" "the file has no pages" = "die Datei hat keine Seiten" "the plugin" = "das Plugin" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index c3e75570..8f1f09a2 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1571,7 +1571,10 @@ name = "English" "set-variable actions with values that don't fit the variable were skipped" = "set-variable actions with values that don't fit the variable were skipped" "set-variable actions without a value were skipped" = "set-variable actions without a value were skipped" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "some vector segments refer to style settings missing from the file; they use the layer's stroke" "stars and polygons without point counts or geometry use the default point count" = "stars and polygons without point counts or geometry use the default point count" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "style settings of some vector segments aren't supported; those segments use the layer's stroke" "text style overrides refer to missing table entries" = "text style overrides refer to missing table entries" "the file has no pages" = "the file has no pages" "the plugin" = "the plugin" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 49dd765f..8552b617 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1572,7 +1572,10 @@ status = "draft — needs native review" "set-variable actions with values that don't fit the variable were skipped" = "se omitieron las acciones «Establecer variable» con valores que no encajan con la variable" "set-variable actions without a value were skipped" = "se omitieron las acciones «Establecer variable» sin valor" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "se omitieron las acciones «Establecer modo de variable» sobre colecciones o modos que no están en el JSON de variables" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "algunos puntos vectoriales hacen referencia a ajustes de punto que faltan en el archivo; usan las uniones de la capa y esquinas simples" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "algunos segmentos vectoriales hacen referencia a ajustes de estilo que faltan en el archivo; usan el trazo de la capa" "stars and polygons without point counts or geometry use the default point count" = "las estrellas y los polígonos sin número de puntos ni geometría usan el número de puntos predeterminado" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "los ajustes de estilo de algunos segmentos vectoriales no son compatibles; esos segmentos usan el trazo de la capa" "text style overrides refer to missing table entries" = "las anulaciones de estilo de texto hacen referencia a entradas de tabla que faltan" "the file has no pages" = "el archivo no tiene páginas" "the plugin" = "el plugin" diff --git a/crates/ui-egui/src/import_report.rs b/crates/ui-egui/src/import_report.rs index b756e871..0540a2fc 100644 --- a/crates/ui-egui/src/import_report.rs +++ b/crates/ui-egui/src/import_report.rs @@ -53,7 +53,10 @@ const IMPORT_NOTES: &[&str] = &[ tr_noop("set-variable actions with values that don't fit the variable were skipped"), tr_noop("set-variable actions without a value were skipped"), tr_noop("set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped"), + tr_noop("some vector points refer to point settings missing from the file; they use the layer's joins and plain corners"), + tr_noop("some vector segments refer to style settings missing from the file; they use the layer's stroke"), tr_noop("stars and polygons without point counts or geometry use the default point count"), + tr_noop("style settings of some vector segments aren't supported; those segments use the layer's stroke"), tr_noop("text style overrides refer to missing table entries"), tr_noop("the file has no pages"), tr_noop("the variables JSON has no `variableCollections` (expected the local variables response) and was ignored"), diff --git a/crates/ui-egui/src/import_report_tests.rs b/crates/ui-egui/src/import_report_tests.rs index cc493e4b..37b51642 100644 --- a/crates/ui-egui/src/import_report_tests.rs +++ b/crates/ui-egui/src/import_report_tests.rs @@ -59,6 +59,19 @@ fn note_text_keeps_the_repeat_suffix_and_unknown_notes() { assert_eq!(note_text("weird (×12) tail"), "weird (×12) tail"); } +#[test] +fn vector_style_notes_are_known_import_notes() { + for note in [ + "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners", + "some vector segments refer to style settings missing from the file; they use the layer's stroke", + "style settings of some vector segments aren't supported; those segments use the layer's stroke", + ] { + let shown = note_text(note); + assert!(shown.chars().next().is_some_and(char::is_uppercase), "{shown}"); + assert_eq!(shown[shown.chars().next().unwrap().len_utf8()..], note[note.chars().next().unwrap().len_utf8()..]); + } +} + #[test] fn opening_a_rest_json_file_reports_what_was_skipped() { let path = std::env::temp_dir().join(format!("figcraft-import-report-{}.json", std::process::id())); From 771f7e869f669f478e0b9277a2123307269272fc Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 09:15:25 +0200 Subject: [PATCH 025/118] M1: reword the unresolved point style import note --- crates/format/src/fig/map.rs | 2 +- crates/format/src/fig/tail_tests.rs | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 914843d3..0760e889 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -942,7 +942,7 @@ impl<'a> Mapper<'a> { self.warn("filled areas of a vector with their own fill use the layer's fill"); } if unresolved_style { - self.warn("vector points with unresolved style IDs lost their point styles"); + self.warn("some vector points refer to point settings missing from the file; they use the layer's joins and plain corners"); } if unknown_join { self.warn("vector points with unknown stroke joins use the layer join"); diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index cd27300d..51871ccd 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -301,7 +301,12 @@ fn vectors_import_as_editable_networks() { let fallback = network_of(&doc, "Join Fallbacks"); assert!(fallback.vertices.iter().all(|v| v.join.is_none())); assert_eq!(named(&doc, "Join Fallbacks").stroke.join, StrokeJoin::Miter); - assert!(warnings.iter().any(|w| w.contains("unresolved style IDs lost their point styles")), "{warnings:?}"); + assert!( + warnings + .iter() + .any(|w| w.contains("vector points refer to point settings missing from the file; they use the layer's joins and plain corners")), + "{warnings:?}" + ); assert!(warnings.iter().any(|w| w.contains("unknown stroke joins use the layer join")), "{warnings:?}"); assert!(warnings.iter().any(|w| w.contains("dashed or variable-width strokes render with the layer join")), "{warnings:?}"); } From d8e64a73bf0686277c0a06c2fdec2bb51261d064 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 09:15:25 +0200 Subject: [PATCH 026/118] M1: reword the vector segment style import notes --- crates/format/src/fig/map.rs | 4 ++-- crates/format/src/fig/tail_tests.rs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 1dc8e138..712a90fd 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1302,10 +1302,10 @@ impl<'a> Mapper<'a> { } } if has_uninterpreted { - self.warn("metadata for styled vector network segments was not interpreted"); + self.warn("style settings of some vector segments aren't supported; those segments use the layer's stroke"); } if has_missing { - self.warn("vector network segments reference missing style entries"); + self.warn("some vector segments refer to style settings missing from the file; they use the layer's stroke"); } // An open path shows its fill, which regions can't hold: the outline keeps the look. let fills_shown = diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 657ff6ff..bc4f31cc 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -280,9 +280,9 @@ fn per_segment_stroke_styles_are_reported() { }; assert_eq!(network::decode_styled(&square_blob()).unwrap().1, 0); let plain = import(&tail_canvas(), "Tail").unwrap().1; - assert!(!plain.iter().any(|w| w.contains("vector network segments")), "{plain:?}"); - let uninterpreted = |w: &String| w.contains("metadata for styled vector network segments was not interpreted"); - let missing = |w: &String| w.contains("vector network segments reference missing style entries"); + assert!(!plain.iter().any(|w| w.contains("vector segments")), "{plain:?}"); + let uninterpreted = |w: &String| w.contains("style settings of some vector segments aren't supported; those segments use the layer's stroke"); + let missing = |w: &String| w.contains("some vector segments refer to style settings missing from the file; they use the layer's stroke"); // Style 1 has a row in the layer's override table; its contents are not applied. let known = styled_import(1); assert_eq!(known.iter().filter(|w| uninterpreted(w)).count(), 1, "{known:?}"); From 41d465a678411260bb1c414e078d774a3b3b4d3b Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 09:15:25 +0200 Subject: [PATCH 027/118] M1: reword the vector segment style import notes --- crates/format/src/fig/map.rs | 4 ++-- crates/format/src/fig/tail_tests.rs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 05fa8566..36f45f31 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1211,10 +1211,10 @@ impl<'a> Mapper<'a> { } } if has_uninterpreted { - self.warn("metadata for styled vector network segments was not interpreted"); + self.warn("style settings of some vector segments aren't supported; those segments use the layer's stroke"); } if has_missing { - self.warn("vector network segments reference missing style entries"); + self.warn("some vector segments refer to style settings missing from the file; they use the layer's stroke"); } // An open path shows its fill, which regions can't hold: the outline keeps the look. let fills_shown = diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 657ff6ff..bc4f31cc 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -280,9 +280,9 @@ fn per_segment_stroke_styles_are_reported() { }; assert_eq!(network::decode_styled(&square_blob()).unwrap().1, 0); let plain = import(&tail_canvas(), "Tail").unwrap().1; - assert!(!plain.iter().any(|w| w.contains("vector network segments")), "{plain:?}"); - let uninterpreted = |w: &String| w.contains("metadata for styled vector network segments was not interpreted"); - let missing = |w: &String| w.contains("vector network segments reference missing style entries"); + assert!(!plain.iter().any(|w| w.contains("vector segments")), "{plain:?}"); + let uninterpreted = |w: &String| w.contains("style settings of some vector segments aren't supported; those segments use the layer's stroke"); + let missing = |w: &String| w.contains("some vector segments refer to style settings missing from the file; they use the layer's stroke"); // Style 1 has a row in the layer's override table; its contents are not applied. let known = styled_import(1); assert_eq!(known.iter().filter(|w| uninterpreted(w)).count(), 1, "{known:?}"); From 12d43b1f997554df0a5681d354bfb7d7e19db933 Mon Sep 17 00:00:00 2001 From: Andrii Volkov <60794004+volkovskey@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:46:08 +0300 Subject: [PATCH 028/118] fix(format): open large .fig files A real 705 MB .fig file (401 644 nodes, 894 004 layers once instances are expanded) didn't open: "message: too many values". Lifting that cap alone made it import only its first 200 000 layers, and lifting that one took 41 GB of memory. The global caps stay as they are. The .fig import gets budgets from the size of its compressed canvas instead (counted up to the file's own size), not from the message it unpacks to, since chunks inflate up to 512 MB: - the kiwi value budget is one value per 2 bytes of the canvas, at least the old 20 million (kiwi is only read for .fig files); - the layer cap is one layer per 32 bytes of the canvas, at least the REST import's 200 000 and at most 2 000 000. A small or crafted file can't expand its instances into millions of layers, however far it unpacks; the REST JSON import keeps 200 000. Memory: - The mapper no longer passes the document through `json!`, which serialises its arguments: the whole tree was deep-copied twice. - Vector networks are built once per distinct network and handed to the REST importer beside the JSON (`vectorNetworkRef`), not written into it as JSON objects for every instance that shows them. - Decoded kiwi objects are trimmed, and each import stage's input is dropped as soon as the next stage is built. On that file: peak footprint 41 GB -> 11.3 GB, 67 s -> 25 s. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki --- crates/format/src/fig/kiwi.rs | 24 +++++- crates/format/src/fig/map.rs | 127 +++++++++++++++++++--------- crates/format/src/fig/mod.rs | 30 +++++-- crates/format/src/fig/tail_tests.rs | 4 +- crates/format/src/fig/tests.rs | 61 ++++++++++++- crates/format/src/rest/mod.rs | 54 ++++++++++-- crates/format/src/rest/tests.rs | 15 ++++ 7 files changed, 251 insertions(+), 64 deletions(-) diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs index 528dbbf0..46a9daac 100644 --- a/crates/format/src/fig/kiwi.rs +++ b/crates/format/src/fig/kiwi.rs @@ -10,8 +10,12 @@ use std::fmt; /// Deepest struct/message nesting decoded. const MAX_DEPTH: usize = 64; -/// Most values decoded from one message (every scalar, array element and field counts). -const MAX_VALUES: usize = 20_000_000; +/// Most values decoded from one message (every scalar, array element and field counts): at least +/// [`MIN_VALUES`], and one per [`PACKED_BYTES_PER_VALUE`] bytes of the file's compressed canvas, +/// so a large real file decodes while a small one can't expand into a huge tree, however far its +/// chunks inflate. +const MIN_VALUES: usize = 20_000_000; +const PACKED_BYTES_PER_VALUE: usize = 2; /// Most definitions in a schema, and fields in one definition. const MAX_DEFS: usize = 10_000; const MAX_FIELDS: usize = 10_000; @@ -284,13 +288,23 @@ impl Schema { self.defs.iter().position(|d| d.name == name) } - /// Decode a message of definition `root`. + /// Decode a message of definition `root`, within [`MIN_VALUES`] values. pub fn decode_message<'s>(&'s self, root: usize, data: &[u8]) -> Result, KiwiError> { - let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget: MAX_VALUES }; + self.decode_message_within(root, data, MIN_VALUES) + } + + /// [`Self::decode_message`] within `budget` values (see [`value_budget`]). + pub fn decode_message_within<'s>(&'s self, root: usize, data: &[u8], budget: usize) -> Result, KiwiError> { + let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget }; d.def(root, 0) } } +/// Most values decoded from a file whose compressed canvas is `packed` bytes. +pub(crate) fn value_budget(packed: usize) -> usize { + MIN_VALUES.max(packed / PACKED_BYTES_PER_VALUE) +} + struct Decoder<'s, 'b> { schema: &'s Schema, r: Reader<'b>, @@ -327,6 +341,8 @@ impl<'s> Decoder<'s, '_> { loop { let id = self.r.var_uint()?; if id == 0 { + // Grown by doubling: trimmed, a large file's tree takes a good part less. + out.shrink_to_fit(); return Ok(Kv::Object(out)); } let f = d.fields.iter().find(|f| f.value == id).ok_or_else(|| KiwiError(format!("message: {} has no field {id}", d.name)))?; diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 66a00129..28fd90f5 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -6,9 +6,10 @@ //! are expanded here from the main component (overrides and derived layout matched by id path, //! component property values applied) so the import looks as saved. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::rc::Rc; +use figcraft_doc::VectorNetwork; use figcraft_doc::geom::{Affine, Point}; use figcraft_doc::proto::{MAX_ACTION_DEPTH, MAX_EXPR_DEPTH}; use serde_json::{Map, Value, json}; @@ -18,8 +19,7 @@ use super::network; type Obj = Map; -/// Most layers produced (instance expansion included) and deepest nesting followed. -const MAX_NODES: usize = crate::rest::MAX_NODES; +/// Deepest nesting followed. const MAX_DEPTH: usize = 256; /// Most path commands read from one geometry blob. const MAX_COMMANDS: usize = 200_000; @@ -28,11 +28,15 @@ const NO_STYLE: &str = "4294967295:4294967295"; /// Longest asset `key` or `version` kept for resolving references given as assets. const MAX_ASSET_PART: usize = 256; -/// The REST API file JSON for message `msg`, with the warnings about what was skipped. -pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec) { - let mut m = Mapper::new(msg); +/// The REST API file JSON for message `msg`, with the warnings about what was skipped and the +/// vector networks its layers name by `vectorNetworkRef` (for [`crate::rest::import_with_networks`]). +pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec, Vec>) { + let mut m = Mapper::new(msg, max_nodes); let doc = m.document(); - let mut out = json!({"name": name, "document": doc}); + // Large values are moved in, never passed through `json!`, which serialises its arguments: + // a deep copy of the whole tree, briefly holding it twice. + let mut out = json!({"name": name}); + out["document"] = doc; if !m.styles.is_empty() { out["styles"] = Value::Object(std::mem::take(&mut m.styles)); } @@ -49,7 +53,7 @@ pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec) { if !styles.is_empty() { out["styleDefinitions"] = Value::Array(styles); } - (out, m.warnings) + (out, m.warnings, m.networks) } /// REST importer warnings reworded for a `.fig` import (`None`: drop it). @@ -261,10 +265,18 @@ struct Mapper<'a> { sets: Obj, warnings: Vec, count: usize, + /// Most layers produced, instance expansion included (see [`super::layer_budget`]). + max_nodes: usize, + /// Vector networks, each built once: the layers that show one (every instance of a main + /// holding it) name it by `vectorNetworkRef`. Kept out of the JSON, where a large file's + /// networks took gigabytes. + networks: Vec>, + /// Index in `networks` by what a network is made from: its blob, its scale and its style table. + network_ids: HashMap<(usize, u64, u64, usize), usize>, } impl<'a> Mapper<'a> { - fn new(msg: &'a Kv<'a>) -> Self { + fn new(msg: &'a Kv<'a>, max_nodes: usize) -> Self { let mut nodes = BTreeMap::new(); let mut kids: BTreeMap> = BTreeMap::new(); let mut prop_defs = BTreeMap::new(); @@ -302,7 +314,21 @@ impl<'a> Mapper<'a> { let version = n.get("version").and_then(Kv::str).filter(|v| !v.is_empty() && v.len() <= MAX_ASSET_PART).unwrap_or(""); assets.entry(key.to_string()).or_default().entry(version.to_string()).or_insert(id); } - Self { nodes, kids, blobs, prop_defs, assets, styles: Obj::new(), components: Obj::new(), sets: Obj::new(), warnings: Vec::new(), count: 0 } + Self { + nodes, + kids, + blobs, + prop_defs, + assets, + styles: Obj::new(), + components: Obj::new(), + sets: Obj::new(), + warnings: Vec::new(), + count: 0, + max_nodes, + networks: Vec::new(), + network_ids: HashMap::new(), + } } /// The id of the resource a `VariableSetID` (or `VariableID`, `StyleId`) names: its `guid`, @@ -360,7 +386,9 @@ impl<'a> Mapper<'a> { if out.is_empty() { self.warn("the file has no pages"); } - json!({"id": doc_id.unwrap_or_else(|| "0:0".into()), "type": "DOCUMENT", "children": out}) + let mut doc = json!({"id": doc_id.unwrap_or_else(|| "0:0".into()), "type": "DOCUMENT"}); + doc["children"] = Value::Array(out); + doc } fn page(&mut self, id: &str, n: &'a Kv<'a>) -> Value { @@ -409,8 +437,9 @@ impl<'a> Mapper<'a> { self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); return None; } - if self.count >= MAX_NODES { - self.warn(format!("the file has more than {MAX_NODES} layers; the rest were skipped")); + if self.count >= self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the file has more than {max} layers; the rest were skipped")); return None; } let base = *self.nodes.get(id)?; @@ -521,7 +550,7 @@ impl<'a> Mapper<'a> { fn shape_with_text(&mut self, o: &mut Obj, v: &View<'a>, own: &str, rest_id: &str) -> Option { let shape = v.str("shapeWithTextType").unwrap_or("SQUARE"); let outlined = !v.list("fillGeometry").is_empty() || !v.list("strokeGeometry").is_empty(); - if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= MAX_NODES { + if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= self.max_nodes { return None; } let mut s = Obj::new(); @@ -553,7 +582,7 @@ impl<'a> Mapper<'a> { /// layer filling it, inset a little; centred unless it's a note. fn inner_text(&mut self, o: &Obj, v: &View<'a>, rest_id: &str, note: bool) -> Option { let chars = own_text(v)?; - if self.count >= MAX_NODES { + if self.count >= self.max_nodes { return None; } self.count += 1; @@ -587,7 +616,7 @@ impl<'a> Mapper<'a> { if own_text(v).is_some() { self.warn("connector labels were skipped"); } - if ["vectorNetwork", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) { + if ["vectorNetwork", "vectorNetworkRef", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) { return; } let parent = v.get("parentIndex").and_then(|p| p.get("guid")).and_then(guid); @@ -879,7 +908,8 @@ impl<'a> Mapper<'a> { /// False when there's none or it's damaged (the outlines stand in). fn network(&mut self, o: &mut Obj, v: &View<'a>) -> bool { let Some(vd) = v.get("vectorData") else { return false }; - let Some(blob) = num(vd, "vectorNetworkBlob").and_then(|i| self.blobs.get(i as usize)).copied() else { return false }; + let Some(blob_id) = num(vd, "vectorNetworkBlob").map(|i| i as usize) else { return false }; + let Some(blob) = self.blobs.get(blob_id).copied() else { return false }; let Some(net) = network::decode(blob) else { self.warn("damaged vector networks import as outlines"); return false; @@ -899,28 +929,6 @@ impl<'a> Mapper<'a> { size.zip(norm).filter(|(s, n)| s.is_finite() && *n > 1e-9).map_or(1.0, |(s, n)| s / n) }; let (sx, sy) = (scale("x"), scale("y")); - let vertices: Vec = net - .vertices - .iter() - .map(|p| { - let mut out = json!({"x": p.x * sx, "y": p.y * sy}); - let st = style(p.style); - if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) { - out["radius"] = json!(r); - } - match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) { - Some("ANGLE") => out["mirror"] = json!("angle"), - Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"), - Some("NONE") => out["mirror"] = json!("none"), - _ => {} - } - out - }) - .collect(); - let segments: Vec = - net.segments.iter().map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]})).collect(); - let regions: Vec = - net.regions.iter().map(|r| json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }})).collect(); if net.regions.iter().any(|r| style(r.style).is_some_and(|s| s.get("fillPaints").is_some())) { self.warn("filled areas of a vector with their own fill use the layer's fill"); } @@ -946,7 +954,46 @@ impl<'a> Mapper<'a> { o.insert("strokeCapStart".into(), json!(cap_name(first.unwrap_or(node)))); o.insert("strokeCapEnd".into(), json!(cap_name(last.unwrap_or(node)))); } - o.insert("vectorNetwork".into(), json!({"vertices": vertices, "segments": segments, "regions": regions})); + // Every instance of a main shows the same network: it is written once and shared. + let key = (blob_id, sx.to_bits(), sy.to_bits(), std::ptr::from_ref(vd) as usize); + let id = match self.network_ids.get(&key) { + Some(id) => *id, + None => { + let vertices: Vec = net + .vertices + .iter() + .map(|p| { + let mut out = json!({"x": p.x * sx, "y": p.y * sy}); + let st = style(p.style); + if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) { + out["radius"] = json!(r); + } + match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) { + Some("ANGLE") => out["mirror"] = json!("angle"), + Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"), + Some("NONE") => out["mirror"] = json!("none"), + _ => {} + } + out + }) + .collect(); + let segments: Vec = net + .segments + .iter() + .map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]})) + .collect(); + let regions: Vec = + net.regions.iter().map(|r| json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }})).collect(); + let mut entry = json!({}); + entry["vertices"] = Value::Array(vertices); + entry["segments"] = Value::Array(segments); + entry["regions"] = Value::Array(regions); + self.networks.push(crate::rest::network(&entry)); + self.network_ids.insert(key, self.networks.len() - 1); + self.networks.len() - 1 + } + }; + o.insert("vectorNetworkRef".into(), json!(id)); true } diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index ec2a0e23..0f6a38ff 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -39,6 +39,18 @@ const MAX_CHUNK: u64 = 512 * 1024 * 1024; const MAX_IMAGE: u64 = 256 * 1024 * 1024; /// Most chunks read from `canvas.fig`. const MAX_CHUNKS: usize = 16; +/// Most layers imported from a `.fig` file: one per [`PACKED_BYTES_PER_LAYER`] bytes of its +/// compressed canvas, at least the REST import's cap and at most [`MAX_FIG_NODES`]. Instances +/// expand into a layer per layer of their component, and chunks inflate up to [`MAX_CHUNK`], so the +/// budget follows what the file takes, not what it unpacks to: a small file can't claim a large +/// count, however it's built; a large one gets room. +const PACKED_BYTES_PER_LAYER: usize = 32; +const MAX_FIG_NODES: usize = 2_000_000; + +/// Most layers imported from a `.fig` file whose compressed canvas is `packed` bytes. +pub(crate) fn layer_budget(packed: usize) -> usize { + (packed / PACKED_BYTES_PER_LAYER).clamp(crate::rest::MAX_NODES, MAX_FIG_NODES) +} /// Whether `bytes` look like a `.fig` file: a zip holding `canvas.fig`, or a bare canvas. pub fn is_fig(bytes: &[u8]) -> bool { @@ -57,14 +69,22 @@ pub fn try_import(bytes: &[u8], name: &str) -> Option Result<(Document, Vec), FormatError> { - let archive = Archive::open(bytes)?; - let (schema, message) = decode_canvas(&archive.canvas)?; + let mut archive = Archive::open(bytes)?; + // The budgets follow the compressed canvas, which the file itself bounds (its zip member may + // inflate too). + let packed = archive.canvas.len().min(bytes.len()); + // Each stage's input is dropped as soon as the next one is built: in a large file every one + // of them takes gigabytes, and holding them all at once runs out of memory. + let (schema, message) = decode_canvas(&std::mem::take(&mut archive.canvas))?; let root = schema.find("Message").ok_or_else(|| corrupt("the schema has no Message definition"))?; - let msg = schema.decode_message(root, &message).map_err(|e| corrupt(e.to_string()))?; + let msg = schema.decode_message_within(root, &message, kiwi::value_budget(packed)).map_err(|e| corrupt(e.to_string()))?; + let max_nodes = layer_budget(packed); + drop(message); let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name); - let (json, mut warnings) = map::to_rest(&msg, file_name); + let (json, mut warnings, networks) = map::to_rest(&msg, file_name, max_nodes); + drop(msg); let images = |r: &str| archive.image(r); - let (doc, more) = crate::rest::import(&json, &images)?; + let (doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?; warnings.extend(more.into_iter().filter_map(map::adapt_warning)); Ok((doc, warnings)) } diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index fc0c5815..9b834805 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -392,8 +392,8 @@ fn mangled_tail_files_never_panic() { m[i] = r.next() as u8; } if let Ok(kv) = sc.decode_message(root, &m) { - let (json, _) = map::to_rest(&kv, "x"); - let _ = crate::rest::import(&json, &crate::rest::no_images); + let (json, _, networks) = map::to_rest(&kv, "x", crate::rest::MAX_NODES); + let _ = crate::rest::import_with_networks(&json, &crate::rest::no_images, networks, crate::rest::MAX_NODES); } } } diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 19c4fc15..574f860c 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -588,7 +588,7 @@ fn mangled_kiwi_never_panics() { } for cut in (0..msg.len()).step_by(3) { if let Ok(kv) = sc.decode_message(root, &msg[..cut]) { - let _ = map::to_rest(&kv, "x"); + let _ = map::to_rest(&kv, "x", crate::rest::MAX_NODES); } } for _ in 0..2000 { @@ -598,8 +598,8 @@ fn mangled_kiwi_never_panics() { m[i] = r.next() as u8; } if let Ok(kv) = sc.decode_message(root, &m) { - let (json, _) = map::to_rest(&kv, "x"); - let _ = crate::rest::import(&json, &crate::rest::no_images); + let (json, _, networks) = map::to_rest(&kv, "x", crate::rest::MAX_NODES); + let _ = crate::rest::import_with_networks(&json, &crate::rest::no_images, networks, crate::rest::MAX_NODES); } let mut sbytes = schema_bytes.clone(); let i = (r.next() as usize) % sbytes.len(); @@ -621,6 +621,59 @@ fn mangled_kiwi_never_panics() { } } +#[test] +fn large_files_get_a_larger_value_budget() { + // Small files keep the fixed floor: a few bytes can't expand into a huge tree. + assert_eq!(kiwi::value_budget(0), 20_000_000); + assert_eq!(kiwi::value_budget(1 << 20), 20_000_000); + // A real 400 000-layer file: an 83.5 MB canvas whose message unpacks to 334 MB and holds + // 30.5 million values was refused as "too many values". + assert!(kiwi::value_budget(83_491_232) > 30_514_899); + // Still bounded by the largest canvas read. + assert!(kiwi::value_budget(MAX_CANVAS as usize) <= 128 << 20); +} + +#[test] +fn the_layer_cap_follows_the_file_size() { + // A small (or broken) file keeps the REST import's cap: instances expand into a layer per + // layer of their component, and a few bytes mustn't claim millions of layers. + assert_eq!(layer_budget(0), crate::rest::MAX_NODES); + assert_eq!(layer_budget(1 << 20), crate::rest::MAX_NODES); + // A real 894 004-layer file (an 83.5 MB canvas) stopped at 200 000 and came in with 27 of + // its 30 pages empty. + assert!(layer_budget(83_491_232) > 894_004); + // No file gets more than the ceiling. + assert_eq!(layer_budget(MAX_CANVAS as usize), MAX_FIG_NODES); +} + +#[test] +fn a_small_file_that_unpacks_large_gets_no_larger_budgets() { + // 40 MB of zeros in a blob pack into a few KB. Keyed to the unpacked message, they lifted the + // layer cap above the 231 231 layers its instances expand into; keyed to the file, the cap + // stays at the REST import's. + let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])]; + nodes.push(node(5, Some((1, "!")), "SYMBOL", "Main", vec![("size", xy(10.0, 10.0))])); + nodes.extend((10..1010).map(|i| node(i, Some((5, "!")), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))]))); + nodes.extend((2000..2230).map(|i| node(i, Some((1, "!")), "INSTANCE", "I", vec![("symbolData", o(vec![("symbolID", g(5))]))]))); + let blob = o(vec![("bytes", Kv::Bytes(vec![0; 40 << 20]))]); + let file = canvas(&schema(), &o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(vec![blob]))])); + assert!(file.len() < 1 << 20, "{} bytes", file.len()); + let (doc, warnings) = import(&file, "x").unwrap(); + assert!(warnings.iter().any(|w| w.contains("more than 200000 layers")), "{warnings:?}"); + assert!(doc.node_count() <= crate::rest::MAX_NODES + 10); +} + +#[test] +fn the_mapper_stops_at_the_layer_cap_it_is_given() { + let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])]; + nodes.extend((2..12).map(|i| node(i, Some((1, "!")), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))]))); + let msg = o(vec![("nodeChanges", arr(nodes))]); + let (_, warnings, _) = map::to_rest(&msg, "x", 5); + assert!(warnings.iter().any(|w| w.contains("more than 5 layers")), "{warnings:?}"); + let (_, warnings, _) = map::to_rest(&msg, "x", crate::rest::MAX_NODES); + assert!(!warnings.iter().any(|w| w.contains("more than")), "{warnings:?}"); +} + #[test] fn hostile_input_is_refused() { // A message that contains itself, nested past the depth cap. @@ -916,7 +969,7 @@ fn dump_samples() { std::fs::write(format!("{out}/{stem}.schema.txt"), txt).unwrap(); let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); std::fs::write(format!("{out}/{stem}.json"), serde_json::to_string_pretty(&debug_json(&msg)).unwrap()).unwrap(); - let (rest, _) = map::to_rest(&msg, &stem); + let (rest, ..) = map::to_rest(&msg, &stem, super::layer_budget(message.len())); std::fs::write(format!("{out}/{stem}.rest.json"), serde_json::to_string_pretty(&rest).unwrap()).unwrap(); } } diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 798b2645..1bd029c6 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -49,7 +49,7 @@ use crate::FormatError; use props::{MAX_COORD, MAX_SIZE, clip_str, flag, list, num, text}; pub use variables::export as export_variables; -/// Most layers imported from one file. +/// Most layers imported from one file. The `.fig` import sets its own, from the file's size. pub const MAX_NODES: usize = 200_000; /// Deepest layer nesting followed. const MAX_DEPTH: usize = 256; @@ -138,6 +138,28 @@ pub fn import(v: &Value, images: ImageResolver) -> Result<(Document, Vec /// in the file JSON (a `meta` with `variableCollections`, or `variableCollections` and /// `variables` at the top level) are read too; the first definition of an id wins. pub fn import_with(v: &Value, variables: Option<&Value>, images: ImageResolver) -> Result<(Document, Vec), FormatError> { + import_parts(v, variables, images, Vec::new(), MAX_NODES) +} + +/// [`import`] for the `.fig` import's JSON, whose vectors name their networks by +/// `vectorNetworkRef`, an index in `networks` (built once each, and far smaller than as JSON). +/// `max_nodes`: the layer cap, set by the `.fig` import in place of [`MAX_NODES`]. +pub(crate) fn import_with_networks( + v: &Value, + images: ImageResolver, + networks: Vec>, + max_nodes: usize, +) -> Result<(Document, Vec), FormatError> { + import_parts(v, None, images, networks, max_nodes) +} + +fn import_parts( + v: &Value, + variables: Option<&Value>, + images: ImageResolver, + networks: Vec>, + max_nodes: usize, +) -> Result<(Document, Vec), FormatError> { let entries: Vec<&Value> = if is_document(v) { vec![v] } else { nodes_entries(v).collect() }; if entries.is_empty() { return Err(FormatError::Corrupt("not a REST API file JSON (no `document`)".into())); @@ -157,6 +179,8 @@ pub fn import_with(v: &Value, variables: Option<&Value>, images: ImageResolver) cx.warn("the variables JSON has no `variableCollections` (expected the local variables response) and was ignored"); } cx.style_definitions(v); + cx.networks = networks; + cx.max_nodes = max_nodes; for e in entries { let Some(root) = e.get("document") else { continue }; match text(root, "type") { @@ -309,6 +333,10 @@ pub(crate) struct Ctx<'a> { /// Imported variables by id, and the mode ids of imported collections (default first). var_kinds: BTreeMap, var_modes: BTreeMap>, + /// Vector networks built by the `.fig` import, named by layers' `vectorNetworkRef`. + networks: Vec>, + /// Most layers imported: [`MAX_NODES`], or the `.fig` import's own cap. + max_nodes: usize, } impl<'a> Ctx<'a> { @@ -332,6 +360,8 @@ impl<'a> Ctx<'a> { overlay_meta: BTreeMap::new(), var_kinds: BTreeMap::new(), var_modes: BTreeMap::new(), + networks: Vec::new(), + max_nodes: MAX_NODES, } } @@ -387,8 +417,9 @@ impl<'a> Ctx<'a> { self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); return; } - if self.count >= MAX_NODES { - self.warn(format!("the file has more than {MAX_NODES} layers; the rest were skipped")); + if self.count >= self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the file has more than {max} layers; the rest were skipped")); return; } let Some(ty) = text(j, "type") else { @@ -586,12 +617,17 @@ impl<'a> Ctx<'a> { } } - /// A vector's network: the editable one when the JSON carries it (`vectorNetwork`, our - /// extension, written by the `.fig` import), else from its geometry. A stroke-only vector - /// uses the stroke outline, painted with the stroke paints (exact look, editable as a shape). + /// A vector's network: the editable one when the JSON carries it (`vectorNetwork`, or + /// `vectorNetworkRef` naming one built by the `.fig` import: our extensions), else from its + /// geometry. A stroke-only vector uses the stroke outline, + /// painted with the stroke paints (exact look, editable as a shape). fn vector(&mut self, n: &mut Node, j: &Value) { - if let Some(net) = j.get("vectorNetwork") { - match network(net) { + let net = match j.get("vectorNetworkRef") { + Some(r) => Some(r.as_u64().and_then(|i| self.networks.get(usize::try_from(i).ok()?).cloned().flatten())), + None => j.get("vectorNetwork").map(network), + }; + if let Some(net) = net { + match net { Some(network) => { n.kind = NodeKind::Vector { network }; return; @@ -1110,7 +1146,7 @@ fn local(parent: Affine, world: Affine) -> Affine { /// Finite path data within the coordinate cap. /// A vector network in our own JSON shape, when it's within bounds and refers only to what it has. -fn network(v: &Value) -> Option { +pub(crate) fn network(v: &Value) -> Option { use serde::Deserialize; let mut net = VectorNetwork::deserialize(v).ok()?; let entries: usize = net.regions.iter().flat_map(|r| &r.loops).map(Vec::len).sum(); diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index d7117fd0..58dfc5ef 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -707,3 +707,18 @@ fn layer_count_is_capped() { assert_eq!(doc.node_count(), MAX_NODES + 1); assert!(warnings.iter().any(|w| w.contains("more than")), "{warnings:?}"); } + +#[test] +fn the_fig_import_sets_its_own_layer_cap() { + // The `.fig` import passes a cap from the file's size: above `MAX_NODES` every layer comes in. + let kids: Vec = (0..MAX_NODES + 5).map(|i| json!({"id": i.to_string(), "type": "RECTANGLE", "name": "R"})).collect(); + let json = file(json!([page("Page", Value::Array(kids))])); + let (doc, warnings) = import_with_networks(&json, &no_images, Vec::new(), MAX_NODES + 10).unwrap(); + assert_eq!(doc.node_count(), MAX_NODES + 6); + assert!(!warnings.iter().any(|w| w.contains("more than")), "{warnings:?}"); + // Below it, the import stops at the cap it was given. + let few: Vec = (0..10).map(|i| json!({"id": i.to_string(), "type": "RECTANGLE", "name": "R"})).collect(); + let (doc, warnings) = import_with_networks(&file(json!([page("Page", Value::Array(few))])), &no_images, Vec::new(), 4).unwrap(); + assert_eq!(doc.node_count(), 5); + assert!(warnings.iter().any(|w| w.contains("more than 4 layers")), "{warnings:?}"); +} From 0efddc3894be71e31a3d10bc0ece63cdff3df550 Mon Sep 17 00:00:00 2001 From: Andrii Volkov <60794004+volkovskey@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:46:08 +0300 Subject: [PATCH 029/118] fix(format): reopen large documents once saved `document.json` was read under the 512 MB cap for any archive member, so a large imported document saved but didn't open again: 900 000 layers save as a 1.48 GB document.json, packed into 244 MB. It may now unpack to 16 times its packed size (counted up to the archive's own size), within 512 MB and 4 GB; images and other members keep 512 MB. A small archive still can't unpack into gigabytes. That saved file reopens in 13 s with a 5.9 GB peak. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki --- crates/format/src/large_tests.rs | 15 +++++++++++++++ crates/format/src/lib.rs | 14 +++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/format/src/large_tests.rs b/crates/format/src/large_tests.rs index 3ff352ac..2dc9eb4c 100644 --- a/crates/format/src/large_tests.rs +++ b/crates/format/src/large_tests.rs @@ -202,3 +202,18 @@ fn the_header_is_read_on_its_own() { let (_, _, index) = parse_json(&d.replacen("\"imageIndex\":[]", "\"imageIndex\":[{\"hash\":\"ab\"},{\"nope\":1}]", 1)).unwrap(); assert_eq!(index, Some(["ab".to_string()].into_iter().collect())); } + +#[test] +fn document_json_may_unpack_in_proportion_to_its_packed_size() { + const MB: u64 = 1024 * 1024; + // A small archive keeps the member cap: it can't unpack into gigabytes. + assert_eq!(document_limit(0, 0), MAX_MEMBER); + assert_eq!(document_limit(MB, 2 * MB), MAX_MEMBER); + // A real 900 000-layer document saved by us packs 1 477 139 810 bytes into 244 362 923, and + // didn't open again under the 512 MB cap. + assert!(document_limit(244_362_923, 260 * MB) > 1_477_139_810); + // The packed size an archive claims counts only up to the archive's own size. + assert_eq!(document_limit(u64::MAX, MB), MAX_MEMBER); + // Never more than the ceiling. + assert_eq!(document_limit(u64::MAX, u64::MAX), MAX_DOCUMENT); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index f88416e2..dd96b932 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -39,6 +39,11 @@ pub const FORMAT: &str = "figcraft"; pub const VERSION: u32 = 1; /// Largest archive member we read (guards against zip bombs). const MAX_MEMBER: u64 = 512 * 1024 * 1024; +/// `document.json` may unpack to [`DOCUMENT_RATIO`] times what it takes in the archive, within +/// [`MAX_MEMBER`] and [`MAX_DOCUMENT`]: a large document saved by us (a 900 000-layer file packs +/// 1.48 GB into 244 MB) opens again, while a small archive still can't unpack into gigabytes. +const DOCUMENT_RATIO: u64 = 16; +const MAX_DOCUMENT: u64 = 4 * 1024 * 1024 * 1024; /// Most versions a file keeps: saving more is refused, extra entries in a file are skipped. pub const MAX_VERSIONS: usize = 100; /// Longest version name and description kept (chars). @@ -455,6 +460,11 @@ pub(crate) fn read_member(z: &mut zip::ZipArchive, name: &str Ok(data) } +/// Largest `document.json` read from an archive of `len` bytes in which it takes `packed`. +pub(crate) fn document_limit(packed: u64, len: u64) -> u64 { + packed.min(len).saturating_mul(DOCUMENT_RATIO).clamp(MAX_MEMBER, MAX_DOCUMENT) +} + /// How many images, and how many bytes of them together, one file may give. #[derive(Clone, Copy, Debug)] pub(crate) struct ImageBudget { @@ -476,7 +486,9 @@ pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result Date: Fri, 16 Oct 2026 10:23:57 +0200 Subject: [PATCH 030/118] M4: Flatten keeps open paths Flatten (Cmd+E, Alt+Shift+F) and Join selection (Cmd+J) built the result from each layer's fill outline, so open paths, which have none, were dropped: two pen paths flattened to "nothing to flatten", and next to a closed shape they vanished. Vectors now bring their own network (open paths, unfilled loops and each region's winding stay), mapped into the parent's space; other layers still flatten to their outline. Closes #377 --- ROADMAP.md | 4 +- crates/engine/src/cmd/structure.rs | 53 +++++++++++++-------- crates/engine/src/shapes_behaviour_tests.rs | 24 ++++++++++ docs/roadmap.md | 3 +- 4 files changed, 62 insertions(+), 22 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c3c12085..5eb2ba18 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Flatten keeps open paths (#377); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-16** · Flatten keeps open paths (M4, #377): Flatten (Cmd+E, Alt+Shift+F) and Join selection (Cmd+J) built the result from each layer's fill outline, so open paths, which have none, were dropped: two pen paths flattened to "nothing to flatten", and next to a closed shape they vanished. Vectors now bring their own network (open paths, unfilled loops and each region's winding stay), mapped into the parent's space; other layers still flatten to their outline. Regression test in the shapes behaviour tests. Numbers unchanged. - **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. @@ -313,6 +314,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Flatten keeps open paths (M4, #377): Flatten (Cmd+E, Alt+Shift+F) and Join selection (Cmd+J) built the result from each layer's fill outline, so open paths, which have none, were dropped: two pen paths flattened to "nothing to flatten", and next to a closed shape they vanished. Vectors now bring their own network (open paths, unfilled loops and each region's winding stay), mapped into the parent's space; other layers still flatten to their outline. Regression test in the shapes behaviour tests. Numbers unchanged | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | | 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index bed70d3d..3fb1dc2b 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -309,33 +309,46 @@ fn flatten(s: &mut Session, p: &Value) -> Result { let parent = doc.get(top)?.parent.ok_or_else(|| bad("vector.flatten", "can't flatten a page"))?; let index = doc.index_in_parent(top).map(|i| i + 1); let pw = doc.world_transform(parent); - let mut path = BezPath::new(); - let mut even = false; + let mut net = VectorNetwork::default(); for i in &ids { let Some(n) = doc.node(*i) else { continue }; - // Booleans flatten to their result; strokes don't get outlined. - let Some((mut op, rule)) = (match &n.kind { - NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => figcraft_render::subtree_outline(doc, n, 0), - _ => figcraft_render::node_outline(doc, n), - }) else { - continue; + // Vectors keep their own network (open paths and unfilled loops have no fill outline); + // booleans flatten to their result; strokes don't get outlined. + let mut part = match &n.kind { + NodeKind::Vector { network } => network.clone(), + _ => { + let Some((op, rule)) = (match &n.kind { + NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => { + figcraft_render::subtree_outline(doc, n, 0) + } + _ => figcraft_render::node_outline(doc, n), + }) else { + continue; + }; + let mut part = VectorNetwork::from_path(&op); + if rule == figcraft_render::FillRule::EvenOdd { + for r in &mut part.regions { + r.winding = WindingRule::EvenOdd; + } + } + part + } }; - even |= rule == figcraft_render::FillRule::EvenOdd; // Into the common parent's space. - let w = doc.world_transform(*i); - op.apply_affine(pw.inverse() * w); - path.extend(op.elements().iter().copied()); + let all: Vec = (0..part.vertices.len()).collect(); + crate::vectoredit::ops::transform_vertices(&mut part, &all, pw.inverse() * doc.world_transform(*i)); + let (nv, ns) = (net.vertices.len(), net.segments.len()); + net.vertices.extend(part.vertices); + net.segments.extend(part.segments.into_iter().map(|s| Segment { a: s.a + nv, b: s.b + nv, ..s })); + net.regions.extend( + part.regions.into_iter().map(|r| Region { loops: r.loops.into_iter().map(|l| l.into_iter().map(|s| s + ns).collect()).collect(), ..r }), + ); } - if path.elements().is_empty() { + let Some(bb) = net.bounds() else { return Err(EngineError::Disabled("vector.flatten".into(), "nothing to flatten".into())); - } + }; let first = ids.first().and_then(|i| doc.node(*i)).cloned(); - let bb = path.bounding_box(); - path.apply_affine(Affine::translate((-bb.x0, -bb.y0))); - let mut net = VectorNetwork::from_path(&path); - for r in &mut net.regions { - r.winding = if even { WindingRule::EvenOdd } else { WindingRule::NonZero }; - } + net.translate(-bb.x0, -bb.y0); let vid = doc.alloc_id(); let name = first.as_ref().map(|f| f.name.clone()).unwrap_or_else(|| "Vector".into()); let mut v = Node::new(vid, NodeKind::Vector { network: net }, name, bb.width(), bb.height()); diff --git a/crates/engine/src/shapes_behaviour_tests.rs b/crates/engine/src/shapes_behaviour_tests.rs index 154c5ae1..62069da3 100644 --- a/crates/engine/src/shapes_behaviour_tests.rs +++ b/crates/engine/src/shapes_behaviour_tests.rs @@ -396,6 +396,30 @@ fn boolean_results_and_the_look_they_take() { assert!(path.area().abs() < 1e-6, "no overlap, nothing to show"); } +#[test] +fn flatten_keeps_open_paths() { + // #377: open paths have no fill outline, so two pen paths flattened to "nothing to flatten" + // and next to a closed shape they disappeared. + let mut s = Session::new(); + let a = create(&mut s, "vector.create", json!({"points": [[0, 0], [100, 50], [200, 0]]})); + let b = create(&mut s, "vector.create", json!({"points": [[0, 100], [100, 150], [200, 100]]})); + let tri = create(&mut s, "vector.create", json!({"points": [[300, 0], [400, 0], [400, 100]], "closed": true})); + let v = create(&mut s, "vector.flatten", json!({"ids": [a, b, tri]})); + let NodeKind::Vector { network } = node(&s, v).kind else { panic!("flatten makes one vector") }; + assert_eq!((network.vertices.len(), network.segments.len()), (9, 7), "both open paths and the triangle"); + assert_eq!(network.regions.len(), 1, "only the triangle is filled"); + let bb = s.doc().unwrap().world_bounds(v).unwrap(); + assert_eq!((bb.x0, bb.y0, bb.x1, bb.y1), (0.0, 0.0, 400.0, 150.0), "nothing moves"); + + // Joining two open paths goes the same way. + let mut s = Session::new(); + let a = create(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0]]})); + let b = create(&mut s, "vector.create", json!({"points": [[0, 50], [100, 50]]})); + let v = create(&mut s, "vector.join", json!({"ids": [a, b]})); + let NodeKind::Vector { network } = node(&s, v).kind else { panic!("join makes one vector") }; + assert_eq!(network.segments.len(), 2); +} + #[test] fn flatten_and_outline_stroke() { // Flatten: one vector layer from the selection, in one step; Alt+Shift+F does it. diff --git a/docs/roadmap.md b/docs/roadmap.md index 9e9eaec9..8acce139 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Flatten keeps open paths (#377); community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -73,6 +73,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Flatten keeps open paths (M4, #377): Flatten (Cmd+E, Alt+Shift+F) and Join selection (Cmd+J) built the result from each layer's fill outline, so open paths, which have none, were dropped: two pen paths flattened to "nothing to flatten", and next to a closed shape they vanished. Vectors now bring their own network (open paths, unfilled loops and each region's winding stay), mapped into the parent's space; other layers still flatten to their outline. Regression test in the shapes behaviour tests. Numbers unchanged | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-14 | minor | M7: the Small caps button in Type settings showed a box where the UI font has no small-capital `ɢ` (builds without Inter use egui's fonts); its sample is now a capital and a smaller capital, drawn from ordinary letters every UI font has. Numbers unchanged | | 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | From a911ab9614c3bc01c19ad86035bd8872a7ffc9cf Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:29:55 +0000 Subject: [PATCH 031/118] Reject negative or fractional index in fill.set and stroke.set Found while testing crates/engine/src/cmd/edit.rs:1180 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/lib.rs | 2 ++ crates/engine/src/paint_index_tests.rs | 39 ++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/paint_index_tests.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2c767b93..35930cb7 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1177,7 +1177,7 @@ fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result Result { let cmd = if fills { "fill.set" } else { "stroke.set" }; let ids = req_targets(cmd, s, p)?; - let idx = num(p, "index").map(|x| x.max(0.0) as usize); + let idx = if get(p, "index").is_none() { None } else { Some(req_index(cmd, p, "index")?) }; let new_paint = match get(p, "paint") { Some(v) => Some(paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?), None => None, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..a84ca21b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1594,6 +1594,8 @@ mod page_copy_navigation_tests; #[cfg(test)] mod page_target_tests; #[cfg(test)] +mod paint_index_tests; +#[cfg(test)] mod paint_reorder_tests; #[cfg(test)] mod paint_tests; diff --git a/crates/engine/src/paint_index_tests.rs b/crates/engine/src/paint_index_tests.rs new file mode 100644 index 00000000..eddea306 --- /dev/null +++ b/crates/engine/src/paint_index_tests.rs @@ -0,0 +1,39 @@ +//! `fill.set` / `stroke.set` reject a negative, fractional or non-finite `index` instead of +//! clamping or truncating it onto another paint (like `fill.remove` / `stroke.remove`). + +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::Session; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn paints(s: &Session, id: NodeId) -> String { + let n = s.doc().unwrap().node(id).unwrap(); + format!("{:?} {:?}", n.fills, n.strokes) +} + +#[test] +fn paint_set_rejects_bad_index_and_leaves_the_paints_alone() { + let mut s = Session::new(); + let r = NodeId(ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap()); + for add in ["fill.add", "stroke.add"] { + for c in ["#0000FF", "#FFFF00"] { + ex(&mut s, add, json!({"ids": [r], "color": c})); + } + } + let before = paints(&s, r); + for cmd in ["fill.set", "stroke.set"] { + for bad in [json!(-1), json!(0.5), json!(1.9), json!("-1"), json!("1.5"), json!("NaN"), json!("inf"), json!("abc")] { + let res = s.execute(cmd, &json!({"ids": [r], "index": bad, "color": "#FF0000"})); + assert!(res.is_err(), "{cmd} index {bad} was accepted: {res:?}"); + assert_eq!(paints(&s, r), before, "{cmd} index {bad} changed the document"); + } + ex(&mut s, cmd, json!({"ids": [r], "index": 1, "color": "#FF0000"})); + assert_ne!(paints(&s, r), before, "{cmd} with a valid index still edits"); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(paints(&s, r), before); + } +} From 11455e0fe9f7f74a1411065ccab815293de149c0 Mon Sep 17 00:00:00 2001 From: loyet Date: Fri, 16 Oct 2026 10:44:29 +0200 Subject: [PATCH 032/118] M1: .fig import reads images carried in the message (image.dataBlob) Copies written by plugins and tools carry their image bytes in the message: a paint's image.dataBlob indexes the message's blobs. A bare canvas (what the clipboard holds, see #647) has no images/, so these fills came in as grey placeholders. The importer now falls back to the embedded bytes, keyed by image hash, after the archive's images/. Blobs over the image size cap and indexes past the blobs are skipped and stay placeholders. Tests: a bare canvas carrying its image in a blob, and a dataBlob past the blobs. --- crates/format/src/fig/mod.rs | 43 ++++++++++++++++++++++++++--- crates/format/src/fig/tests.rs | 49 +++++++++++++++++++++++++++++++++- docs/file-format-parity.md | 5 ++-- 3 files changed, 91 insertions(+), 6 deletions(-) diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index ec2a0e23..091492da 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -6,7 +6,8 @@ //! zstd: a binary schema in the kiwi format ([`kiwi`]), then a message encoded with it. Every file //! carries its own schema, so the message is decoded generically and read by field name: a list //! of node changes (each with its id, parent id and position among its siblings) that make up the -//! layer tree, plus blobs (vector outlines and editable vector networks, [`network`]). [`map`] +//! layer tree, plus blobs (vector outlines and editable vector networks, [`network`], and the bytes +//! of images that travel inside the message, [`embedded_images`]). [`map`] //! turns that into the REST API file JSON shape and the REST importer builds the document, so both //! imports share one mapping. Layer kinds the editor doesn't have (sticky notes, connectors, //! tables, …) come in as the closest frames, text and vectors. Anything not understood is skipped @@ -23,6 +24,7 @@ mod network; #[cfg(test)] mod tests; +use std::collections::BTreeMap; use std::io::Read; use figcraft_doc::Document; @@ -30,7 +32,7 @@ use figcraft_doc::Document; pub use export::export; use crate::FormatError; -use kiwi::Schema; +use kiwi::{Kv, Schema}; /// Largest `canvas.fig` member read, and largest decompressed chunk. const MAX_CANVAS: u64 = 256 * 1024 * 1024; @@ -63,7 +65,8 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), Forma let msg = schema.decode_message(root, &message).map_err(|e| corrupt(e.to_string()))?; let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name); let (json, mut warnings) = map::to_rest(&msg, file_name); - let images = |r: &str| archive.image(r); + let embedded = embedded_images(&msg); + let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).cloned()); let (doc, more) = crate::rest::import(&json, &images)?; warnings.extend(more.into_iter().filter_map(map::adapt_warning)); Ok((doc, warnings)) @@ -94,6 +97,40 @@ fn corrupt(msg: impl Into) -> FormatError { FormatError::Corrupt(format!(".fig file: {}", msg.into())) } +/// Image bytes that travel inside the message, by image hash (lowercase hex): a paint's +/// `image.dataBlob` indexes the message's `blobs`. Copies written by plugins and other tools embed +/// their images this way, and a bare canvas (what the clipboard carries) has no `images/`. The +/// walk is iterative (no recursion on nested data) and skips blobs larger than [`MAX_IMAGE`]. +fn embedded_images(msg: &Kv) -> BTreeMap> { + let blobs = msg.get("blobs").map(Kv::list).unwrap_or_default(); + let mut found = BTreeMap::new(); + let mut pending = vec![msg]; + while let Some(value) = pending.pop() { + match value { + Kv::Object(fields) => { + if let Some((hash, bytes)) = value.get("image").and_then(|image| embedded_image(image, blobs)) { + found.entry(hash).or_insert_with(|| bytes.to_vec()); + } + pending.extend(fields.iter().filter(|(name, _)| *name != "blobs").map(|(_, field)| field)); + } + Kv::Array(items) => pending.extend(items), + _ => {} + } + } + found +} + +/// The hash and bytes of an `Image` value whose `dataBlob` points at a usable blob. +fn embedded_image<'k>(image: &Kv, blobs: &'k [Kv]) -> Option<(String, &'k [u8])> { + let hash = image.get("hash").and_then(Kv::bytes).filter(|h| !h.is_empty() && h.len() <= 64)?; + let Some(Kv::Uint(index)) = image.get("dataBlob") else { return None }; + let bytes = blobs.get(usize::try_from(*index).ok()?)?.get("bytes").and_then(Kv::bytes)?; + if bytes.is_empty() || bytes.len() as u64 > MAX_IMAGE { + return None; + } + Some((hash.iter().map(|b| format!("{b:02x}")).collect(), bytes)) +} + /// The parts of a `.fig` archive the import reads. struct Archive<'b> { canvas: Vec, diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 19c4fc15..c8749513 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -38,7 +38,7 @@ struct Color float r, float g, float b, float a struct Vector float x, float y struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 struct ColorStop Color color, float position -message Image byte[] hash, string name +message Image byte[] hash, string name, uint dataBlob message AssetRef string key, string version message VariableID GUID guid, AssetRef assetRef message PropRefValue GUID defId @@ -539,6 +539,53 @@ fn bare_canvas_and_zlib_chunks_open() { assert!(import(&out, "Z").is_ok()); } +/// The fixture message with the photo's image bytes in its own blobs, as copies written by plugins +/// and tools carry them: the PNG goes at the end of `blobs` and `image.dataBlob` points `shift` +/// places past it (0 is the PNG itself). +fn message_with_image_in_blobs(shift: u64) -> Kv<'static> { + fn point_images_at(value: &mut Kv<'static>, blob: u64) { + match value { + Kv::Object(fields) => { + for (name, field) in fields.iter_mut() { + match field { + Kv::Object(image) if *name == "image" => image.push(("dataBlob", u(blob))), + _ => point_images_at(field, blob), + } + } + } + Kv::Array(items) => items.iter_mut().for_each(|item| point_images_at(item, blob)), + _ => {} + } + } + let mut msg = message(); + let index = { + let Kv::Object(fields) = &mut msg else { panic!("the message is an object") }; + let Some((_, Kv::Array(blobs))) = fields.iter_mut().find(|(name, _)| *name == "blobs") else { panic!("no blobs") }; + blobs.push(o(vec![("bytes", Kv::Bytes(png()))])); + blobs.len() as u64 - 1 + }; + point_images_at(&mut msg, index + shift); + msg +} + +#[test] +fn a_bare_canvas_reads_images_carried_in_its_blobs() { + // The clipboard holds a bare canvas (no `images/`); the image comes from `image.dataBlob`. + let c = canvas(&schema(), &message_with_image_in_blobs(0)); + let (doc, warnings) = import(&c, "Pasted").unwrap(); + let photo = named(&doc, "Photo"); + let figcraft_doc::color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!("not an image fill") }; + assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((2, 1))); + assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); +} + +#[test] +fn a_data_blob_past_the_blobs_stays_a_placeholder() { + let c = canvas(&schema(), &message_with_image_in_blobs(5)); + let (_, warnings) = import(&c, "Pasted").unwrap(); + assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); +} + #[test] fn other_files_are_not_fig() { assert!(try_import(b"{\"document\": {}}", "x").is_none()); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d8e51711..c9e5da2f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (`.fig` import: images carried in the message (`image.dataBlob`); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -17,7 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills (from the archive, or carried in the message itself as `image.dataBlob`, the way copies written by plugins and tools carry them) come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout, constraints, grids, components, sets, instances with overrides, component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | @@ -162,6 +162,7 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | `.fig` import: image fills whose bytes travel in the message itself (`image.dataBlob`, an index into the message's blobs) come across. Copies written by plugins and tools carry their images this way, and a bare canvas, the shape the clipboard holds, has no `images/`: they were grey placeholders. Blobs over the image size cap and indexes past the blobs are skipped (placeholder, as before). Checked on a bare canvas generated in code and on copies written by an HTML-to-Figma browser extension. Numbers unchanged | | 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. | | 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Opened `.fig` and file JSON documents link nested instances of variants (and any nested instance under a name of its own) to their main's layers; they stayed unlinked, so a second export lost what they changed. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | | 2026-10-12 | minor | `.fig` export made to open elsewhere (gap 1): the document and its pages say they are visible and opaque (left out, nothing was drawn), the schema in the file uses the format's own names, kinds, field ids and enum values, rectangles are rounded rectangles with four radii, frames and shapes carry their fill outline and plain strokes their stroke outline, text carries its layout settings, font styles use their standard names, line heights that are a share of the font size are raw multipliers, masks keep their type (read back too); the import reads per-side stroke weights whose zero sides are left out. Written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers unchanged | From b962603707a78e61cd457b15c93c53effe81909b Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Fri, 16 Oct 2026 10:46:36 +0200 Subject: [PATCH 033/118] M4: points dropped on a point merge In vector edit mode a point dragged onto another point of its path snapped there but stayed a separate point, so a path couldn't be closed or joined by dragging. A single dragged point dropped within the point hit radius (6 px) of another point now merges into it, in the same undo step as the move, and that point stays selected. Closes #401 --- ROADMAP.md | 4 ++- crates/engine/src/vectoredit/pointer.rs | 37 ++++++++++++++++++++++++- crates/engine/src/vectoredit/tests.rs | 22 +++++++++++++++ docs/roadmap.md | 3 +- 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c3c12085..129c44bb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (points dropped on a point merge (#401); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-16** · Points dropped on a point merge (M4, #401): in vector edit mode a point dragged onto another point of its path snapped there but stayed a separate point, so the path couldn't be closed or joined by dragging. Dropped within the point hit radius (6 px) of another point, a single dragged point now merges into it in the same undo step as the move, and that point stays selected; an open path's end dropped on its start closes it. Join (Cmd+J) still merges selected points that overlap. Regression test in the vector edit tests. Numbers unchanged. - **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. @@ -313,6 +314,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Points dropped on a point merge (M4, #401): in vector edit mode a point dragged onto another point of its path snapped there but stayed a separate point, so the path couldn't be closed or joined by dragging. Dropped within the point hit radius (6 px) of another point, a single dragged point now merges into it in the same undo step as the move, and that point stays selected; an open path's end dropped on its start closes it. Join (Cmd+J) still merges selected points that overlap. Regression test in the vector edit tests. Numbers unchanged | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | | 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/vectoredit/pointer.rs index 388fe245..a42b0603 100644 --- a/crates/engine/src/vectoredit/pointer.rs +++ b/crates/engine/src/vectoredit/pointer.rs @@ -996,8 +996,13 @@ impl Session { self.tool.overlay.guides.clear(); let Gesture::Vector(g) = g else { return Ok(()) }; match g { - VGesture::Points { moved, split, click_clears, .. } => { + VGesture::Points { verts, others, moved, split, click_clears, .. } => { if moved || split.is_some() { + if let ([v], None) = (verts.as_slice(), split) + && others.iter().all(|(_, o)| o.is_empty()) + { + self.merge_dropped_point(ve.id, *v, 6.0 / ev.zoom.max(1e-6))?; + } self.commit_interaction()?; } else { self.cancel_interaction()?; @@ -1057,4 +1062,34 @@ impl Session { } Ok(()) } + + /// Point `v` of vector `id`, dragged and dropped within `tol` (page units) of another of its + /// points, merges into that point (in the same undo step as the move), which stays selected. + fn merge_dropped_point(&mut self, id: NodeId, v: usize, tol: f64) -> Result<()> { + let moved = self.ds()?.doc.get(id)?.clone(); + let w = self.ds()?.doc.world_transform(id); + let NodeKind::Vector { network: net } = &moved.kind else { return Ok(()) }; + let Some(p) = net.vertices.get(v).map(|x| w * x.point()) else { return Ok(()) }; + let Some(keep) = net + .vertices + .iter() + .enumerate() + .filter(|(i, _)| *i != v) + .map(|(i, x)| (i, (w * x.point() - p).hypot())) + .filter(|(_, d)| *d <= tol) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| i) + else { + return Ok(()); + }; + // The preview starts again from the document before the drag: bring the moved layer. + self.preview(|doc, _| { + *doc.get_mut(id)? = moved; + edit(doc, id, |net| ops::merge_vertices(net, keep, v)) + })?; + set_state(self, |e| { + e.vertices = vec![if keep > v { keep - 1 } else { keep }]; + e.segments.clear(); + }) + } } diff --git a/crates/engine/src/vectoredit/tests.rs b/crates/engine/src/vectoredit/tests.rs index ae7ac27e..63469d67 100644 --- a/crates/engine/src/vectoredit/tests.rs +++ b/crates/engine/src/vectoredit/tests.rs @@ -280,6 +280,28 @@ fn mirroring_position_and_join_commands() { assert!(s.execute("vector.join", &json!({"ids": [id]})).is_ok()); } +#[test] +fn a_point_dropped_on_another_merges_into_it() { + // #401: an open path's end dragged onto its start closes the path at that point. + let mut s = Session::new(); + let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0], [100, 100], [0, 100]]})); + let id = crate::params::id_of(&r["id"]).unwrap(); + ex(&mut s, "vector.edit.begin", json!({"id": id})); + drag(&mut s, (0.0, 100.0), (2.0, 3.0)); + let n = net(&s, id); + assert_eq!(n.vertices.len(), 3, "the dropped point merged"); + assert!((0..3).all(|v| n.incident(v).len() == 2), "a closed path: {n:?}"); + assert_eq!(ve(&s).vertices, vec![vertex_at(&n, 0.0, 0.0)], "the point it merged into stays selected"); + s.undo().unwrap(); + let n = net(&s, id); + assert_eq!(n.vertices.len(), 4, "one undo step takes back the move and the merge"); + vertex_at(&n, 0.0, 100.0); + // A drop away from every point only moves it. + ex(&mut s, "vector.edit.select", json!({"vertices": []})); + drag(&mut s, (0.0, 100.0), (0.0, 50.0)); + assert_eq!(net(&s, id).vertices.len(), 4); +} + #[test] fn undo_back_past_the_conversion_leaves_edit_mode() { let mut s = Session::new(); diff --git a/docs/roadmap.md b/docs/roadmap.md index 9e9eaec9..680c5b68 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (points dropped on a point merge (#401); community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -73,6 +73,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Points dropped on a point merge (M4, #401): in vector edit mode a point dragged onto another point of its path snapped there but stayed a separate point, so the path couldn't be closed or joined by dragging. Dropped within the point hit radius (6 px) of another point, a single dragged point now merges into it in the same undo step as the move, and that point stays selected; an open path's end dropped on its start closes it. Join (Cmd+J) still merges selected points that overlap. Regression test in the vector edit tests. Numbers unchanged | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-14 | minor | M7: the Small caps button in Type settings showed a box where the UI font has no small-capital `ɢ` (builds without Inter use egui's fonts); its sample is now a capital and a smaller capital, drawn from ordinary letters every UI font has. Numbers unchanged | | 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | From 2cac36a4e22ca0b177c9852875a6f959f8966be7 Mon Sep 17 00:00:00 2001 From: Ahmed5Emad Date: Fri, 16 Oct 2026 12:31:50 +0300 Subject: [PATCH 034/118] Live font preview on hover in font picker (#653) Hovering a family in the Typography font dropdown previews it on the canvas via text.preview; closing without picking restores the original, clicking applies it as one undo step. --- crates/engine/src/cmd/text.rs | 2 +- crates/engine/src/lib.rs | 4 ++-- crates/engine/src/text_style_picker_tests.rs | 25 ++++++++++++++++++++ crates/engine/src/textpreview.rs | 4 ++-- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/style_picker.rs | 2 +- crates/ui-egui/src/typography.rs | 22 ++++++++++++----- 7 files changed, 48 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 14ea1762..3b861895 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -332,7 +332,7 @@ pub fn specs() -> Vec { spec!( "text.preview", "Preview text change", - "{command: style.apply|library.applyStyle|text.setVariations|text.style, params} shows what the command would do on the canvas without an undo step (a hovered text style or named instance, a dragged axis slider), in place of an earlier preview; text.endPreview or any other command ends it, so running the command for real commits it as one undo step", + "{command: style.apply|library.applyStyle|text.setVariations|text.style, params} shows what the command would do on the canvas without an undo step (a hovered font family or style, text style or named instance, a dragged axis slider), in place of an earlier preview; text.endPreview or any other command ends it, so running the command for real commits it as one undo step", false, |s, p| { let c = req_string("text.preview", p, "command")?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc49a90..8c6ca635 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -316,8 +316,8 @@ pub struct DocState { versions_changed: bool, /// A version shown in place of the document (read-only). pub preview: Option, - /// The document under a live text preview (a hovered text style or font instance, a dragged - /// axis slider): `doc` shows the preview until [`textpreview::end`] puts this back. + /// The document under a live text preview (a hovered font family or style, text style or + /// font instance, a dragged axis slider): `doc` shows the preview until [`textpreview::end`] puts this back. pub text_preview: Option>, /// The document as of the last version saved or restored (none yet): autosaves, restores and /// merges only add a checkpoint for changes since. diff --git a/crates/engine/src/text_style_picker_tests.rs b/crates/engine/src/text_style_picker_tests.rs index 37c7dc5c..6ee8cc2c 100644 --- a/crates/engine/src/text_style_picker_tests.rs +++ b/crates/engine/src/text_style_picker_tests.rs @@ -212,6 +212,31 @@ fn previews_while_editing_keep_the_edit() { assert!(links(&s, t).iter().all(Option::is_none)); } +#[test] +fn previews_show_a_hovered_font_family() { + let mut s = new_session(); + let t = text(&mut s, "Preview me"); + s.ds_mut().unwrap().mark_saved(); + let before = s.doc().unwrap().clone(); + let undo = s.ds().unwrap().history.undo.len(); + // Hovering a font family shows it on the canvas: no undo step, not dirty. + s.execute("text.preview", &json!({"command": "text.style", "params": {"ids": [t], "fontFamily": "Arial"}})).unwrap(); + assert_eq!(data(&s, t).style_at(0).font_family, "Arial"); + assert_eq!(s.ds().unwrap().history.undo.len(), undo); + assert!(!s.ds().unwrap().is_dirty()); + // Moving to another family replaces the preview, from the document itself. + s.execute("text.preview", &json!({"command": "text.style", "params": {"ids": [t], "fontFamily": "Georgia"}})).unwrap(); + assert_eq!(data(&s, t).style_at(0).font_family, "Georgia"); + // Closing without picking puts the document back as it was. + assert_eq!(s.execute("text.endPreview", &json!({})).unwrap()["ended"], json!(true)); + assert_eq!(*s.doc().unwrap(), before); + // Clicking commits it as one undo step. + s.execute("text.style", &json!({"ids": [t], "fontFamily": "Georgia"})).unwrap(); + assert_eq!(data(&s, t).style_at(0).font_family, "Georgia"); + assert_eq!(s.ds().unwrap().history.undo.len(), undo + 1); + assert!(s.ds().unwrap().text_preview.is_none()); +} + #[test] fn previews_refuse_what_they_cant_show() { let mut s = new_session(); diff --git a/crates/engine/src/textpreview.rs b/crates/engine/src/textpreview.rs index 507eccc3..da101d5e 100644 --- a/crates/engine/src/textpreview.rs +++ b/crates/engine/src/textpreview.rs @@ -1,6 +1,6 @@ //! Live text previews: what a text command would do, shown on the canvas without an undo step — -//! while the pointer is over a text style in the style browser or a font's named instance, or -//! while a variable font axis slider is dragged. The document under the preview is kept and comes +//! while the pointer is over a font family or style, a text style in the style browser or a +//! font's named instance, or while a variable font axis slider is dragged. The document under the preview is kept and comes //! back when the preview ends ([`end`], `text.endPreview`). Every other command ends it first, so //! committing (running the command for real) applies to the document itself as one undo step, and //! Escape just ends the preview. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f9..d98b9156 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -583,7 +583,7 @@ impl FigApp { } /// Show what `command params` would do on the canvas as a live preview (`text.preview`) while - /// a text style, a named instance or an axis slider is hovered or dragged. Call it every frame + /// a font family, font style, text style, named instance or axis slider is hovered or dragged. Call it every frame /// the preview should stay: it ends on the first frame nothing asks for it. pub fn preview_text(&mut self, ctx: &egui::Context, command: &str, params: Value) { self.ui.text_preview_wanted = true; diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs index 3a4e7b11..1eec3e38 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -746,7 +746,7 @@ fn text_properties(ui: &mut Ui, id: &str, def: &TextStyle, out: &mut Frame) { (family_resp, style_resp) }); crate::right::menu_popup(&family_resp).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()) { + if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()).0 { set(out, &|d| { d.font_family = f.clone(); let styles = figcraft_text::family_styles(&f); diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 873ac0f0..de304d6a 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -181,7 +181,12 @@ fn font_rows(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle] let w = ui.available_width(); let resp = widgets::dropdown(ui, family.as_deref().unwrap_or(tr(MIXED)), w, None); menu_popup(&resp).width(w.max(240.0)).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - if let Some(f) = font_picker(ui, family.as_deref(), resp.clicked()) { + let (picked, hovered) = font_picker(ui, family.as_deref(), resp.clicked()); + if let Some(h) = hovered { + // The canvas shows the hovered family; closing without picking ends the preview. + app.preview_text(ui.ctx(), "text.style", text_style_params(ids, range, json!({"fontFamily": h}))); + } + if let Some(f) = picked { remember_family(ui.ctx(), &f); apply_style(app, "fontFamily", json!(f)); ui.close(); @@ -1049,8 +1054,9 @@ enum PickRow { Family(String), } -/// The searchable family list. Returns the family picked. -pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) -> Option { +/// The searchable family list. Returns the family picked and the family hovered (the caller +/// previews the hovered one on the canvas: closing without picking restores the original). +pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) -> (Option, Option) { let t = Tokens::get(ui.ctx()); let q_id = Id::new("font_query"); let mut q: String = ui.data(|d| d.get_temp(q_id)).unwrap_or_default(); @@ -1066,7 +1072,7 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) let all = figcraft_text::family_names(); let matches: Vec = all.iter().filter(|f| ql.is_empty() || f.to_lowercase().contains(&ql)).cloned().collect(); if search.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { - return matches.into_iter().next(); + return (matches.into_iter().next(), None); } let mut rows: Vec = Vec::new(); if ql.is_empty() { @@ -1079,11 +1085,12 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) } if matches.is_empty() { widgets::label(ui, tr("No fonts match"), t.text_tertiary); - return None; + return (None, None); } rows.extend(matches.into_iter().map(PickRow::Family)); let mut budget = 4; let mut picked = None; + let mut hovered_family = None; egui::ScrollArea::vertical().max_height(380.0).auto_shrink([false, true]).show_rows(ui, ROW, rows.len(), |ui, range| { for r in rows.get(range).unwrap_or(&[]) { let w = ui.available_width(); @@ -1125,11 +1132,14 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) if resp.clicked() { picked = Some(f.clone()); } + if resp.hovered() { + hovered_family = Some(f.clone()); + } } } } }); - picked + (picked, hovered_family) } #[cfg(test)] From c5a8420089de30cc222786efae09db9423e44967 Mon Sep 17 00:00:00 2001 From: Hamza Date: Fri, 16 Oct 2026 16:27:05 +0500 Subject: [PATCH 035/118] Make the control server's connection-limit test pass on macOS `cargo xtask ci` was red on a clean main on macOS: the test's last shutdown runs after the server has answered an invalid request and closed the connection, and `shutdown(2)` on a socket whose peer has already closed is ENOTCONN on macOS and the BSDs where Linux returns success. The test is right about what it checks; it checked a return value that is not portable. Closing the client is what the worker thread waits for, and dropping the socket does that whether or not shutdown reported anything. The file's other six shutdowns run while the peer is still open, which is well defined on both platforms, so they stay checked. With no CI running the tests on pull requests, the local gate is the only signal a contributor gets, and it could not be green on macOS. --- apps/figcraft/src/control_server_tests.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/figcraft/src/control_server_tests.rs b/apps/figcraft/src/control_server_tests.rs index d243fb91..008170fb 100644 --- a/apps/figcraft/src/control_server_tests.rs +++ b/apps/figcraft/src/control_server_tests.rs @@ -160,7 +160,11 @@ fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() { assert_eq!(workers.len(), 1); client.write_all(b"invalid\n").unwrap(); assert_eq!(reply(&mut BufReader::new(client.try_clone().unwrap()))["ok"], false); - client.shutdown(Shutdown::Both).unwrap(); + // The server closes this connection after answering an invalid request, and shutting down + // a socket whose peer has already closed is ENOTCONN on macOS and the BSDs where Linux + // returns Ok. Closing the client is what the worker is waiting for, so the result is moot: + // the other shutdowns in this file run while the peer is still open and stay checked. + let _ = client.shutdown(Shutdown::Both); workers.pop().unwrap().join().unwrap(); } From 0f4c79b629b23d6a2bc792387c83d40d1dc639a4 Mon Sep 17 00:00:00 2001 From: phonedu <135960865+phonedu@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:33:59 +0800 Subject: [PATCH 036/118] M0.9: a script runs as one undo step and resolves `$step`, like command.batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `figcraft-cli run` ran each step of a script on its own, so a script's `{"$step": n}` reached `parent` unresolved and the layer landed on the page while every step reported success — the same commands through `command.batch` (the MCP `batch` tool, the control channel, plugins) put it inside the frame. One `edit.undo` took back one step of a script rather than the script, and a failing step left the earlier ones applied. `Session::batch_step` is now the one path both take — one step of a batch, its `$step` references resolved against the results before it — and `run_script` wraps its steps in `Session::grouped`: the whole script is one undo step, and a step that fails takes the script back. The result lines come out once the script is through, so none of them reports work a later failure took back; a failure prints one line with `rolledBack: true` and exits 1. `docs/cli.md` documents the reference and the output. --- apps/figcraft-cli/src/files.rs | 92 ++++++++++++++++++++++++++++++---- crates/engine/src/lib.rs | 13 ++++- docs/cli.md | 24 ++++++++- 3 files changed, 116 insertions(+), 13 deletions(-) diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 4fb485e1..46c8dd22 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -333,17 +333,31 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe } None => Session::new(), }; - for (i, (cmd, params)) in steps.iter().enumerate() { - let r = s.execute(cmd, params); - s.ui_requests.clear(); - match r { - Ok(v) => println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v})), - Err(e) => { - println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string()})); - eprintln!("error: step {i} ({cmd}) failed: {e}"); - return Ok(ExitCode::from(1)); - } + // One undo step and all-or-nothing, the way the same commands behave through `command.batch` + // (MCP, the control channel, plugins): `{"$step": n}` resolves against the steps before it, and + // a step that fails takes the whole script back instead of leaving the earlier steps applied. + // The result lines are held back until the script is through, so none of them reports work that + // was rolled back. + let mut results: Vec = Vec::with_capacity(steps.len()); + let mut failed_at = 0; + let outcome = s.grouped(&format!("run {script}"), |s| { + for (i, (cmd, params)) in steps.iter().enumerate() { + failed_at = i; + let v = s.batch_step(&results, i, cmd, params)?; + s.ui_requests.clear(); + results.push(v); } + Ok(()) + }); + if let Err(e) = outcome { + let (i, (cmd, _)) = (failed_at, &steps[failed_at]); + println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true})); + eprintln!("error: step {i} ({cmd}) failed: {e}"); + eprintln!("the script's document changes were rolled back"); + return Ok(ExitCode::from(1)); + } + for (i, ((cmd, _), v)) in steps.iter().zip(&results).enumerate() { + println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v})); } if let Some(out) = out { let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?; @@ -422,4 +436,62 @@ mod tests { assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2); std::fs::remove_dir_all(dir).unwrap(); } + + fn temp_dir(tag: &str) -> std::path::PathBuf { + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("figcraft-cli-{tag}-{}-{stamp}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + dir + } + + /// A script resolves `{"$step": n}` like the same commands through `command.batch`. Each step + /// used to run on its own, so the reference reached `parent` unresolved, `node.create` read it + /// as "no parent" and the layer landed on the page — with every step reporting success. + #[test] + fn a_script_puts_a_layer_in_a_layer_an_earlier_step_made() { + let dir = temp_dir("run-step"); + let script = dir.join("script.json"); + std::fs::write( + &script, + r#"[ + {"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}}, + {"command": "node.create", "params": {"type": "rectangle", "name": "child", "parent": {"$step": 0}, "x": 10, "y": 10, "width": 20, "height": 20}} + ]"#, + ) + .unwrap(); + let out = dir.join("out.figcraft"); + run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap(); + + let mut s = Session::empty(); + s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap(); + let doc = s.doc().unwrap(); + let page = s.page().unwrap(); + let top = doc.node(page).unwrap().children.clone(); + assert_eq!(top.len(), 1, "only the frame is top level, not the rectangle too"); + assert_eq!(doc.node(top[0]).unwrap().name, "Card"); + let inner = doc.node(top[0]).unwrap().children.clone(); + assert_eq!(inner.len(), 1, "the rectangle went inside the frame"); + assert_eq!(doc.node(inner[0]).unwrap().name, "child"); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// A step that fails takes the whole script back: nothing is written, and the failure says so. + #[test] + fn a_failed_step_leaves_no_output() { + let dir = temp_dir("run-rollback"); + let script = dir.join("script.json"); + std::fs::write( + &script, + r#"[ + {"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}}, + {"command": "node.set", "params": {"ids": [99999], "props": {"name": "nope"}}} + ]"#, + ) + .unwrap(); + let out = dir.join("out.figcraft"); + // The failure is the script's, not the call's: the CLI reports it and exits non-zero. + run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap(); + assert!(!out.exists(), "a script that failed writes nothing"); + std::fs::remove_dir_all(dir).unwrap(); + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..27212f96 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1135,13 +1135,22 @@ impl Session { self.grouped(label, |s| { let mut results: Vec = Vec::with_capacity(cmds.len()); for (step, (id, p)) in cmds.iter().enumerate() { - let p = if id == "command.batch" { p.clone() } else { resolve_step_refs(p, &results, step, 0)? }; - results.push(s.execute(id, &p)?); + let v = s.batch_step(&results, step, id, p)?; + results.push(v); } Ok(results) }) } + /// One step of a batch: `{"$step": n}` in `params` resolves against `results` (the results of + /// the steps before it), then the command runs. [`Session::batch`] runs a list of these as one + /// undo step; a caller with its own list — the CLI's script runner — uses this so its steps + /// resolve `$step` the same way, inside its own [`Session::grouped`]. + pub fn batch_step(&mut self, results: &[Value], step: usize, cmd: &str, params: &Value) -> Result { + let p = if cmd == "command.batch" { params.clone() } else { resolve_step_refs(params, results, step, 0)? }; + self.execute(cmd, &p) + } + /// Run `f` as one undo step called `label`: the commands it executes push no steps of their /// own, and when it fails the documents stay at their group-start state. pub fn grouped(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result) -> Result { diff --git a/docs/cli.md b/docs/cli.md index 9f5df40e..be54add2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -103,7 +103,29 @@ Runs commands in a headless session (a new document, or `--in`), printing one JS {"command":"frame.create","ok":true,"result":{"id":2},"step":0} ``` -A failing step prints `"ok": false` with the error and exits with code 1 (nothing is saved). +The whole script is **one undo step**, and it is **all-or-nothing**, exactly like running the same +commands through `command.batch` (the MCP tool `batch`, the control channel, plugins): `{"$step": n}` +in a step's params is the `id` in an earlier step's result, so a step can target a layer an earlier +step of the same script created: + +```json +[ + {"command": "node.create", "params": {"type": "frame", "name": "Card", "width": 200, "height": 100}}, + {"command": "node.create", "params": {"type": "text", "parent": {"$step": 0}, "text": "Hi"}} +] +``` + +The result lines are printed once the script is through, so none of them reports work a later step +took back. + +A failing step prints one line with `"ok": false`, the error and `"rolledBack": true`, writes +nothing (`--out` is not saved, and the session's documents go back to how the script found them), +and exits with code 1: + +```text +{"step":1,"command":"node.set","ok":false,"error":"no such node 99999","rolledBack":true} +``` + `--out` saves the result by extension like `convert`. Script formats: a JSON array of `{"command": id, "params": {...}}`, an object From 9daf39e24ce55e369bdb3e1949927f72c18afd91 Mon Sep 17 00:00:00 2001 From: phonedu <135960865+phonedu@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:53:43 +0800 Subject: [PATCH 037/118] M1: a param that names a layer says so when it isn't one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `node.create` read its `parent` as `id(p, "parent").unwrap_or(page)`, so a `parent` that isn't a layer id — a `{"$step": n}` nothing had resolved, a name, a negative number — quietly meant "no parent" and the layer landed on the page, with the call reporting success. The same shape sat on `image.place` and `instance.create`, on `index` (a string index appended where it was asked to insert), and on the `ids` of every command that targets layers (an entry that wasn't an id was dropped and the rest ran). A number that names nothing was reported; anything that isn't a number at all was not. `params::opt_id` and `params::opt_index` now say what the commands mean: missing and `null` are "none", and anything else has to be a layer id (or a whole, non-negative number) and is named when it isn't. `req_targets` refuses an `ids` entry that isn't an id, before falling back to the selection. A number that names no layer still reports `no such node`, and a boolean is still a number to `f64_of`, as it is for `x`, `width` and the rest. This turns a silently wrong result into an error on purpose, so a caller that passed something that isn't an id and relied on the fallback now hears about it. --- crates/engine/src/cmd/component.rs | 2 +- crates/engine/src/cmd/create.rs | 6 +- .../engine/src/id_params_behaviour_tests.rs | 151 ++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/params.rs | 38 ++++- 5 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/id_params_behaviour_tests.rs diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 0fb64816..e35c3f5b 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -68,7 +68,7 @@ pub fn specs() -> Vec { let cmd = "instance.create"; let c = req_id(cmd, p, "component")?; let page = s.page()?; - let parent = id(p, "parent").unwrap_or(page); + let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page); let doc = s.doc_mut()?; let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; let (x, y) = match (num(p, "x"), num(p, "y")) { diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 2150701a..9b471290 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -167,7 +167,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result { let t = string(p, "type").ok_or_else(|| bad(cmd, "`type` is required"))?; let (mut kind, base) = kind_for(&t).ok_or_else(|| bad(cmd, format!("unknown type `{t}`")))?; let page = s.page()?; - let parent = id(p, "parent").unwrap_or(page); + let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page); let doc = s.doc()?; let pn = doc.get(parent)?; if !pn.kind.is_container() { @@ -255,7 +255,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result { node.width = tw; node.height = th; } - let index = num(p, "index").map(|i| i.max(0.0) as usize); + let index = crate::params::opt_index(cmd, p, "index")?; doc.insert(node, parent, index)?; if let Some(Value::Object(props)) = get(p, "props") { super::props::apply(cmd, doc, nid, props)?; @@ -340,7 +340,7 @@ pub fn place_image(s: &mut Session, p: &Value) -> Result { _ => ImageScaleMode::Fill, }; let page = s.page()?; - let parent = id(p, "parent").unwrap_or(page); + let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page); let ds = s.ds_mut()?; let doc = ds.doc_mut(); let (hash, iw, ih) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?; diff --git a/crates/engine/src/id_params_behaviour_tests.rs b/crates/engine/src/id_params_behaviour_tests.rs new file mode 100644 index 00000000..ae77c8a1 --- /dev/null +++ b/crates/engine/src/id_params_behaviour_tests.rs @@ -0,0 +1,151 @@ +//! Layer ids in params: naming something that isn't a layer is a mistake, and says so, rather than +//! quietly meaning "none". +//! +//! `node.create`'s `parent` was `id(p, "parent").unwrap_or(page)`, so any value that isn't an id — +//! a `{"$step": n}` nothing had resolved, a name, a negative number — became "no parent" and the +//! layer landed on the page while the call reported success. The same shape sat on `image.place` +//! and `instance.create`'s parents, on `index`, and on the `ids` of every command that targets +//! layers (an entry that wasn't an id was dropped and the rest ran). + +use crate::params::id_of; +use crate::{EngineError, Session}; +use figcraft_doc::*; +use serde_json::{Value, json}; + +/// The page's children, by name (bottom first). +fn top(s: &Session) -> Vec { + let doc = s.doc().unwrap(); + let page = s.page().unwrap(); + doc.node(page).unwrap().children.iter().filter_map(|c| doc.node(*c).map(|n| n.name.clone())).collect() +} + +/// The message of a failure, or `None` when the call went through. +fn failed(r: std::result::Result) -> Option { + match r { + Ok(_) => None, + Err(e) => Some(e.to_string()), + } +} + +fn rect(parent: Value) -> Value { + json!({"type": "rectangle", "name": "child", "parent": parent, "x": 0, "y": 0, "width": 10, "height": 10}) +} + +fn card(s: &mut Session) -> NodeId { + let v = s.execute("node.create", &json!({"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 100, "height": 100})).unwrap(); + id_of(&v["id"]).unwrap() +} + +#[test] +fn a_parent_that_is_not_an_id_is_refused_rather_than_becoming_the_page() { + let mut s = Session::new(); + let card = card(&mut s); + for parent in [json!({"$step": 0}), json!("Card"), json!(-1), json!(true)] { + let msg = failed(s.execute("node.create", &rect(parent.clone()))).unwrap_or_else(|| panic!("{parent} must be refused")); + assert!(msg.contains("`parent` must be a node id"), "{parent}: {msg}"); + } + assert_eq!(top(&s), ["Card"], "nothing landed on the page"); + let doc = s.doc().unwrap(); + assert!(doc.node(card).unwrap().children.is_empty(), "nor inside the frame"); +} + +#[test] +fn leaving_the_parent_out_or_null_still_means_the_page() { + let mut s = Session::new(); + let card = card(&mut s); + for parent in [None, Some(json!(null))] { + let mut p = rect(json!(0)); + match parent { + None => _ = p.as_object_mut().unwrap().remove("parent"), + Some(v) => p["parent"] = v, + } + s.execute("node.create", &p).unwrap(); + } + assert_eq!(top(&s), ["Card", "child", "child"]); + let doc = s.doc().unwrap(); + assert!(doc.node(card).unwrap().children.is_empty()); +} + +#[test] +fn a_parent_id_that_names_nothing_is_still_reported() { + let mut s = Session::new(); + card(&mut s); + let msg = failed(s.execute("node.create", &rect(json!(99999)))).unwrap_or_else(|| panic!("a missing layer must fail")); + assert!(msg.contains("no such node 99999"), "{msg}"); +} + +#[test] +fn an_index_that_is_not_a_whole_non_negative_number_is_refused() { + let mut s = Session::new(); + let card = card(&mut s); + // (A boolean is a number to `f64_of` — `true` reads as 1 — as for `x`, `width` and every + // other number, so it is deliberately not in this list.) + for index in [json!("last"), json!(1.5), json!(-1), json!(-0.5), json!(1e12)] { + let mut p = rect(json!(card)); + p["index"] = index.clone(); + let msg = failed(s.execute("node.create", &p)).unwrap_or_else(|| panic!("index {index} must be refused")); + assert!(msg.contains("`index`"), "{index}: {msg}"); + } + let doc = s.doc().unwrap(); + assert!(doc.node(card).unwrap().children.is_empty(), "nothing was inserted"); +} + +#[test] +fn a_whole_index_still_inserts_where_it_says() { + let mut s = Session::new(); + let card = card(&mut s); + for name in ["a", "b"] { + s.execute("node.create", &json!({"type": "rectangle", "name": name, "parent": card, "x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + } + s.execute("node.create", &json!({"type": "rectangle", "name": "mid", "parent": card, "index": 1, "x": 0, "y": 0, "width": 10, "height": 10})) + .unwrap(); + let doc = s.doc().unwrap(); + let names: Vec<&str> = doc.node(card).unwrap().children.iter().filter_map(|c| doc.node(*c).map(|n| n.name.as_str())).collect(); + assert_eq!(names, ["a", "mid", "b"]); +} + +#[test] +fn ids_entries_that_are_not_ids_are_refused_instead_of_being_dropped() { + let mut s = Session::new(); + let card = card(&mut s); + // The junk used to be dropped and the rest applied, so `["nope", card]` renamed the frame. + for ids in [json!(["nope"]), json!(["nope", card]), json!([{"$step": 0}, card]), json!([null])] { + let msg = + failed(s.execute("node.set", &json!({"ids": ids, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("{ids} must be refused")); + assert!(msg.contains("`ids`"), "{ids}: {msg}"); + } + let doc = s.doc().unwrap(); + assert_eq!(doc.node(card).unwrap().name, "Card", "nothing was renamed"); +} + +#[test] +fn an_ids_that_is_not_an_array_is_refused_but_null_means_the_selection() { + let mut s = Session::new(); + let card = card(&mut s); + let msg = + failed(s.execute("node.set", &json!({"ids": "nope", "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("a non-array ids must fail")); + assert!(msg.contains("`ids` must be an array"), "{msg}"); + // An explicit null says the same as leaving it out: the selection. + s.execute("select.set", &json!({"ids": [card]})).unwrap(); + s.execute("node.set", &json!({"ids": null, "props": {"name": "Selected"}})).unwrap(); + assert_eq!(s.doc().unwrap().node(card).unwrap().name, "Selected"); +} + +#[test] +fn a_single_id_that_is_not_an_id_is_refused() { + let mut s = Session::new(); + card(&mut s); + let msg = + failed(s.execute("node.set", &json!({"id": {"$step": 0}, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("a bad id must fail")); + assert!(msg.contains("`id` must be a node id"), "{msg}"); +} + +#[test] +fn an_image_and_an_instance_parent_are_checked_the_same_way() { + let mut s = Session::new(); + card(&mut s); + let img = failed(s.execute("image.place", &json!({"data": "aGk=", "parent": {"$step": 0}}))); + assert!(img.unwrap_or_default().contains("`parent` must be a node id"), "image.place"); + let inst = failed(s.execute("instance.create", &json!({"component": 99999, "parent": {"$step": 0}}))); + assert!(inst.is_some(), "instance.create refuses both a bad component and a bad parent"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..fac5f477 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1548,6 +1548,8 @@ mod history_behaviour_tests; #[cfg(test)] mod html_codegen_tests; #[cfg(test)] +mod id_params_behaviour_tests; +#[cfg(test)] mod import_export_tests; #[cfg(test)] mod inspect_behaviour_tests; diff --git a/crates/engine/src/params.rs b/crates/engine/src/params.rs index bbaa42a4..67a5b80a 100644 --- a/crates/engine/src/params.rs +++ b/crates/engine/src/params.rs @@ -88,6 +88,25 @@ pub fn req_id(cmd: &str, p: &Value, key: &str) -> Result { id(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id"))) } +/// `key` when it is there: a layer id, or an error naming it. Missing and `null` mean "none", so a +/// command can tell "no parent given" (use the page) from "a parent was given that isn't an id" — +/// which is a mistake, not the default. Without this, `{"$step": 0}` reaching a param that isn't +/// resolved yet silently becomes "no parent" and the layer lands on the page. +pub fn opt_id(cmd: &str, p: &Value, key: &str) -> Result> { + match get(p, key) { + None | Some(Value::Null) => Ok(None), + Some(v) => id_of(v).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id (got {v})"))), + } +} + +/// `key` when it is there: a whole, non-negative number, or an error. Missing means "none". +pub fn opt_index(cmd: &str, p: &Value, key: &str) -> Result> { + match get(p, key) { + None | Some(Value::Null) => Ok(None), + Some(_) => req_index(cmd, p, key).map(Some), + } +} + /// `ids` (array), `id` (single), or the current selection. pub fn targets(s: &Session, p: &Value) -> Vec { if let Some(Value::Array(a)) = get(p, "ids") { @@ -99,8 +118,25 @@ pub fn targets(s: &Session, p: &Value) -> Vec { s.selection() } -/// Like [`targets`] but an empty result is an error. +/// Like [`targets`] but an unparseable `ids` entry, an unparseable `id`, or an empty result is an +/// error: naming something that isn't a layer is a mistake, not an empty selection. An explicit +/// `null` says the same as leaving the key out. pub fn req_targets(cmd: &str, s: &Session, p: &Value) -> Result> { + match get(p, "ids") { + Some(Value::Array(a)) => { + if let Some(v) = a.iter().find(|v| id_of(v).is_none()) { + return Err(bad(cmd, format!("`ids`: every entry must be a node id (got {v})"))); + } + } + Some(Value::Null) | None => { + if let Some(v) = get(p, "id").filter(|v| !v.is_null()) + && id_of(v).is_none() + { + return Err(bad(cmd, format!("`id` must be a node id (got {v})"))); + } + } + Some(v) => return Err(bad(cmd, format!("`ids` must be an array (got {v})"))), + } let t = targets(s, p); if t.is_empty() { return Err(EngineError::Disabled(cmd.to_string(), "nothing is selected (pass `ids`)".into())); From 0e25cf4c2651db9395024cff75b7e1c5958bc7b8 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:47:07 +0000 Subject: [PATCH 038/118] Refuse pages in edit.copy and edit.cut Found while testing crates/engine/src/cmd/edit.rs:499 (copy_to_clipboard, called from copy at :536 and cut at :542) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/edit.rs | 11 +++++++++-- crates/engine/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2c767b93..ab503a02 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -499,7 +499,12 @@ fn bump_name(n: &str) -> String { pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> { let ds = s.ds()?; let doc = &ds.doc; - let ids = z_sorted(doc, &roots(doc, ids)); + // Pages are not layers: they can't be pasted anywhere. + let layers: Vec = ids.iter().copied().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect(); + if layers.is_empty() && !ids.is_empty() { + return Err(EngineError::Disabled("edit.copy".into(), "pages can't be copied; use page.duplicate".into())); + } + let ids = z_sorted(doc, &roots(doc, &layers)); let mut nodes = Vec::new(); let mut images = BTreeMap::new(); for i in &ids { @@ -542,7 +547,9 @@ fn copy(s: &mut Session, p: &Value) -> Result { fn cut(s: &mut Session, p: &Value) -> Result { let ids = req_targets("edit.cut", s, p)?; copy_to_clipboard(s, &ids)?; - delete(s, &json!({"ids": ids})) + let doc = s.doc()?; + let layers: Vec = ids.into_iter().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect(); + delete(s, &json!({"ids": layers})) } /// Paste the clipboard under `parent` with the copied top-left moved to `at` (page coords). diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dbf432d4..6010559e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -621,3 +621,20 @@ fn find_replace_changing_only_the_case_replaces_every_occurrence() { assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft"); assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft"); } + +#[test] +fn copy_and_cut_ignore_pages_so_paste_keeps_the_document_valid() { + for cmd in ["edit.copy", "edit.cut"] { + let mut s = sess(); + let page = s.page().unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let r = s.execute(cmd, &json!({"ids": [page.0]})); + if let Ok(v) = &r { + assert!(v.get("deleted").is_none_or(|d| d == &json!([])), "{cmd} reported the page as deleted: {v}"); + } + let _ = s.execute("edit.paste", &json!({})); + let doc = s.doc().unwrap(); + assert!(doc.validate().is_empty(), "{cmd}: {:?}", doc.validate()); + assert!(doc.node(page).is_some(), "{cmd} removed the page"); + } +} From 554821afef53d20d7894eeddd9b48f7a265a9eb0 Mon Sep 17 00:00:00 2001 From: Barzabel Date: Fri, 16 Oct 2026 17:31:11 +0300 Subject: [PATCH 039/118] M9: a damaged generation field no longer drops a log's flushed edits (#408) A log whose generation isn't its snapshot's was skipped as a leftover of a compaction cut short, but neither generation is under a checksum: one flipped bit made the server serve the older document, report nothing and overwrite the log. Replay such a log over the snapshot instead: replay skips what the snapshot already holds, so a genuine leftover changes nothing, while edits the snapshot lacks are recovered and the damage is reported. --- apps/figcraft-server/src/disk.rs | 6 +- apps/figcraft-server/src/store.rs | 30 +++- apps/figcraft-server/tests/header_recovery.rs | 139 ++++++++++++++++++ docs/server.md | 5 +- 4 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 apps/figcraft-server/tests/header_recovery.rs diff --git a/apps/figcraft-server/src/disk.rs b/apps/figcraft-server/src/disk.rs index f58f25b2..af6e0f99 100644 --- a/apps/figcraft-server/src/disk.rs +++ b/apps/figcraft-server/src/disk.rs @@ -8,8 +8,10 @@ //! length (u32 LE), CRC-32 (u32 LE), one [`Journal`] entry in the wire encoding. //! //! Compaction writes the next generation's snapshot, then an empty log of that generation. A log -//! whose generation isn't the snapshot's is from before a compaction that was cut short: what it -//! holds is in the snapshot already. A log ends at its first damaged record (a write cut short). +//! whose generation isn't the snapshot's is usually from before a compaction that was cut short +//! (what it holds is in the snapshot already), but no checksum covers either generation, so the +//! store replays such a log anyway and reports any change the snapshot lacked. A log ends at its +//! first damaged record (a write cut short). use std::fs::{self, File}; use std::io::{self, Read, Write}; diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index 538adeff..ea9bc626 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -440,7 +440,7 @@ impl Store { && let Ok(log) = disk::read_log(&bytes) && log.generation != generation { - log::info!("file at {}: its log predates the last compaction; it is ignored", path.display()); + log::info!("file at {}: its log isn't of its snapshot's generation; it is replayed over it when the file opens", path.display()); } let bytes = [SNAPSHOT, LOG].iter().filter_map(|n| fs::metadata(path.join(n)).ok()).map(|m| m.len()).sum(); Ok(Entry { meta, links, log: None, bytes, forced: 0 }) @@ -1126,8 +1126,32 @@ impl Store { fresh_log = true; } } - // From before a compaction that was cut short: the snapshot has it all. - Ok(_) => {} + // From before a compaction that was cut short, the snapshot has it all; or a generation + // field damaged, which no checksum covers. Replaying tells them apart without trusting + // it: entries the snapshot holds are skipped, so only edits it lacks change anything. + Ok(log) => { + let before = server.seq(); + let mut stopped = None; + for entry in log.entries { + if let Err(e) = server.replay(entry) { + stopped = Some(e); + break; + } + } + let (snap, found, recovered) = (generation, log.generation, server.seq().saturating_sub(before)); + if recovered > 0 { + issues.push(format!( + "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}), but it held \ + {recovered} changes the snapshot didn't; they were recovered" + )); + } + if let Some(e) = stopped { + issues.push(format!( + "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}) and it stops \ + making sense after {recovered} newer changes ({e}); the rest was dropped" + )); + } + } Err(why) => issues.push(format!("file {file}: {why}; starting a new log")), } for i in issues { diff --git a/apps/figcraft-server/tests/header_recovery.rs b/apps/figcraft-server/tests/header_recovery.rs new file mode 100644 index 00000000..d728908d --- /dev/null +++ b/apps/figcraft-server/tests/header_recovery.rs @@ -0,0 +1,139 @@ +//! A generation field damaged after an abrupt stop must not make the server drop a log that still +//! holds confirmed edits (#408): the log is replayed over the snapshot, which only the changes the +//! snapshot lacks survive, and the damage is reported. A genuine leftover of a compaction cut short +//! still restores silently. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use figcraft_collab::message::{Access, ClientMsg}; +use figcraft_collab::{Client, Outgoing, Replica, Server}; +use figcraft_collab_net::hub::Backend; +use figcraft_doc::Document; +use figcraft_server::config::Config; +use figcraft_server::disk::{self, LOG, SNAPSHOT}; +use figcraft_server::store::{FILES, Store}; +use std::fs; +use std::path::{Path, PathBuf}; + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + let path = std::env::temp_dir().join(format!("figcraft-header-recovery-{}", figcraft_collab_net::token::file_id().unwrap())); + fs::create_dir(&path).unwrap(); + Self(path) + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn page_name(server: &Server) -> String { + let doc = server.document(); + doc.node(doc.first_page().unwrap()).unwrap().name.clone() +} + +/// A published file whose first page was renamed "Kept", flushed to its log (not compacted), then +/// the store dropped without stopping (an abrupt stop). Returns the file id and the final state. +fn renamed_and_stopped(data: &Path) -> (String, Server) { + let mut store = Store::open(data, Config::default().store()).unwrap(); + let snapshot = Replica::new(Document::new("Fixture")).snapshot().unwrap(); + let (info, _, _) = store.publish("Fixture", &snapshot).unwrap(); + let id = info.id; + let mut server = store.open(&id).unwrap(); + let ClientMsg::Hello(hello) = Client::hello("f", "", "Editor", "#FF8800") else { panic!("not a hello") }; + let (_, messages) = server.join_as(hello, Access::Edit).unwrap(); + let Some(Outgoing::To(_, welcome)) = messages.into_iter().next() else { panic!("no welcome") }; + let mut client = Client::join(welcome).unwrap(); + store.changed(&id, &mut server); + let mut edited = client.document().clone(); + let page = edited.first_page().unwrap(); + edited.node_mut(page).unwrap().name = "Kept".into(); + let change = client.edit(&edited).unwrap().unwrap(); + assert!(!server.receive(client.id(), change).is_empty()); + store.changed(&id, &mut server); + assert!(store.flush().is_empty(), "the edit must reach disk"); + assert_eq!(page_name(&server), "Kept"); + let dir = data.join(FILES).join(&id); + assert!(!disk::read_log(&fs::read(dir.join(LOG)).unwrap()).unwrap().entries.is_empty()); + assert_eq!(disk::decode_snapshot(&fs::read(dir.join(SNAPSHOT)).unwrap()).unwrap().1.seq, 0, "the rename is only in the log"); + drop(store); + (id, server) +} + +/// Reopen the data directory: the file's first page name and the problems reported. +fn reopen(data: &Path, id: &str) -> (String, Vec) { + let mut store = Store::open(data, Config::default().store()).unwrap(); + let server = store.open(id).unwrap(); + (page_name(&server), store.problems().to_vec()) +} + +fn flip(path: &Path, at: usize) { + let mut bytes = fs::read(path).unwrap(); + bytes[at] ^= 1; + fs::write(path, bytes).unwrap(); +} + +/// The generation field's first byte in both headers (after the magic and the format byte). +const GENERATION_AT: usize = 5; + +#[test] +fn an_intact_log_restores_its_edits_without_a_report() { + let fixture = Fixture::new(); + let (id, _) = renamed_and_stopped(&fixture.0); + let (name, problems) = reopen(&fixture.0, &id); + assert_eq!(name, "Kept"); + assert!(problems.is_empty(), "{problems:?}"); +} + +#[test] +fn a_damaged_log_generation_keeps_the_edits_and_is_reported() { + let fixture = Fixture::new(); + let (id, _) = renamed_and_stopped(&fixture.0); + let log = fixture.0.join(FILES).join(&id).join(LOG); + flip(&log, GENERATION_AT); + // The records themselves still read: only the header's generation changed. + let read = disk::read_log(&fs::read(&log).unwrap()).unwrap(); + assert!(read.damaged.is_none() && !read.entries.is_empty()); + let (name, problems) = reopen(&fixture.0, &id); + assert_eq!(name, "Kept", "the flushed rename must survive"); + assert!(problems.iter().any(|p| p.contains(&id) && p.contains("generation")), "{problems:?}"); + // And it stays: the recovered edit is now in the file's snapshot. + assert_eq!(reopen(&fixture.0, &id).0, "Kept"); +} + +#[test] +fn a_damaged_snapshot_generation_keeps_the_edits_and_is_reported() { + let fixture = Fixture::new(); + let (id, _) = renamed_and_stopped(&fixture.0); + let snapshot = fixture.0.join(FILES).join(&id).join(SNAPSHOT); + flip(&snapshot, GENERATION_AT); + assert!(disk::check_snapshot(&fs::read(&snapshot).unwrap()).is_ok(), "the generation is outside the checksum"); + let (name, problems) = reopen(&fixture.0, &id); + assert_eq!(name, "Kept", "the flushed rename must survive"); + assert!(problems.iter().any(|p| p.contains(&id) && p.contains("generation")), "{problems:?}"); + assert_eq!(reopen(&fixture.0, &id).0, "Kept"); +} + +#[test] +fn a_damaged_log_format_is_still_reported() { + let fixture = Fixture::new(); + let (id, _) = renamed_and_stopped(&fixture.0); + flip(&fixture.0.join(FILES).join(&id).join(LOG), 4); + let (_, problems) = reopen(&fixture.0, &id); + assert!(problems.iter().any(|p| p.contains(&id) && p.contains("format")), "{problems:?}"); +} + +#[test] +fn a_log_left_by_a_compaction_cut_short_restores_without_a_report() { + let fixture = Fixture::new(); + let (id, server) = renamed_and_stopped(&fixture.0); + // The next snapshot was written (it holds the rename); the old log wasn't replaced yet. + let bytes = disk::encode_snapshot(2, &server.state().unwrap()).unwrap(); + fs::write(fixture.0.join(FILES).join(&id).join(SNAPSHOT), bytes).unwrap(); + let (name, problems) = reopen(&fixture.0, &id); + assert_eq!(name, "Kept"); + assert!(problems.is_empty(), "a leftover log is expected, not damage: {problems:?}"); +} diff --git a/docs/server.md b/docs/server.md index f2e4820c..021d0e3f 100644 --- a/docs/server.md +++ b/docs/server.md @@ -337,8 +337,9 @@ the address it came from and when it was last opened (for expiry). before the server tells anyone about the change. - **Compaction** writes the file's state as the next snapshot and starts an empty log, after `compact_after` entries or `compact_mb` of log, when the last person leaves, and when the server - stops. A log left over from a compaction cut short is recognised and ignored (the snapshot holds - it all). + stops. A log left over from a compaction cut short is replayed over the snapshot, which already + holds it all, so nothing changes. If a damaged generation field made a current log look like such + a leftover, its edits the snapshot lacks are recovered the same way and the damage is reported. - **Damage is skipped and reported, never deleted.** At startup a folder whose description, links or snapshot can't be read is left out and logged; a log that ends in a cut-short or damaged record loses only that end (logged when the file opens). Leftover temporary files are removed. From 8303cfa56f19fd11c13bb4f6933edd5717c7de03 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Fri, 16 Oct 2026 22:52:41 +0700 Subject: [PATCH 040/118] fix(engine): Shift-drag on a selected layer moves the selection (#498) Shift on a selected layer took it out of the selection on press, so the drag that followed had nothing to move, and with several layers selected the grabbed one stayed behind. It now leaves the selection on release, and only when the press didn't become a drag. Where no move can start (a version being viewed), it still leaves on press. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../engine/src/selection_behaviour_tests.rs | 89 +++++++++++++++++++ crates/engine/src/tools.rs | 38 +++++--- docs/ui-parity.md | 2 +- 3 files changed, 115 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/selection_behaviour_tests.rs b/crates/engine/src/selection_behaviour_tests.rs index b65b7936..bc961a4a 100644 --- a/crates/engine/src/selection_behaviour_tests.rs +++ b/crates/engine/src/selection_behaviour_tests.rs @@ -275,3 +275,92 @@ fn locked_layers_selected_in_the_layers_panel_stay_put() { ex(&mut s, "object.toggleLock", json!({"ids": [k.g], "locked": true})); assert_eq!(handle_at(s.doc().unwrap(), &[k.a], Point::new(60.0, 60.0), 1.0), None); } + +#[test] +fn shift_drag_moves_the_selection() { + let mut s = Session::new(); + let k = scene(&mut s); + let at = |s: &Session, i: NodeId| s.doc().unwrap().world_bounds(i).unwrap().origin(); + let home = (Point::new(500.0, 0.0), Point::new(600.0, 0.0)); + // (selection before, Shift-drag to (from r at 520,20), selection after, where r and empty end up, derivation) + let rows: Vec<(Vec, (f64, f64), Vec, (Point, Point), &str)> = vec![ + ( + vec![k.r], + (560.0, 22.0), + vec![k.r], + (Point::new(540.0, 0.0), Point::new(600.0, 0.0)), + "Shift-drag on the selected layer moves it along one axis and keeps it selected", + ), + ( + vec![k.r, k.empty], + (560.0, 22.0), + vec![k.r, k.empty], + (Point::new(540.0, 0.0), Point::new(640.0, 0.0)), + "Shift-drag on one of several selected layers moves them all", + ), + ( + vec![k.empty], + (560.0, 22.0), + vec![k.r, k.empty], + (Point::new(540.0, 0.0), Point::new(640.0, 0.0)), + "Shift-drag on an unselected layer adds it and moves the selection", + ), + (vec![k.r, k.empty], (521.0, 21.0), vec![k.empty], home, "a Shift-press that wobbles under the drag threshold is a click: the layer leaves"), + ]; + for (before, to, after, end, why) in rows { + ex(&mut s, "select.set", json!({"ids": before})); + drag(&mut s, (520.0, 20.0), to, SHIFT); + assert_eq!(sorted(s.selection()), sorted(after), "{why}"); + assert_eq!((at(&s, k.r), at(&s, k.empty)), end, "{why}"); + if end != home { + assert!(s.undo().unwrap(), "{why}: one undo step"); + assert_eq!((at(&s, k.r), at(&s, k.empty)), home, "{why}: undone"); + } + } + // Shift let go before the button: still a Shift-click. + ex(&mut s, "select.set", json!({"ids": [k.r, k.empty]})); + s.pointer(&ev(PointerKind::Down, 520.0, 20.0, SHIFT)).unwrap(); + s.pointer(&ev(PointerKind::Up, 520.0, 20.0, NONE)).unwrap(); + assert_eq!(s.selection(), vec![k.empty], "Shift let go before the button still takes the layer out"); +} + +#[test] +fn alt_shift_drag_on_a_selected_layer_duplicates_it() { + let mut s = Session::new(); + let k = scene(&mut s); + let layers = |s: &Session| s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(); + let n = layers(&s); + let alt_shift = |kind, x, y| { + let mut e = ev(kind, x, y, SHIFT); + e.alt = true; + e + }; + ex(&mut s, "select.set", json!({"ids": [k.r]})); + s.pointer(&alt_shift(PointerKind::Down, 520.0, 20.0)).unwrap(); + s.pointer(&alt_shift(PointerKind::Up, 520.0, 20.0)).unwrap(); + assert_eq!(s.selection(), Vec::::new(), "an Alt+Shift-click is a Shift-click: the layer leaves the selection"); + assert_eq!(layers(&s), n, "and nothing is copied"); + ex(&mut s, "select.set", json!({"ids": [k.r]})); + s.pointer(&alt_shift(PointerKind::Down, 520.0, 20.0)).unwrap(); + s.pointer(&alt_shift(PointerKind::Move, 560.0, 22.0)).unwrap(); + s.pointer(&alt_shift(PointerKind::Up, 560.0, 22.0)).unwrap(); + let sel = s.selection(); + assert_eq!(layers(&s), n + 1, "an Alt+Shift-drag copies the layer"); + assert!(sel.len() == 1 && sel[0] != k.r, "the copy is selected"); + let doc = s.doc().unwrap(); + assert_eq!(doc.world_bounds(sel[0]).unwrap().origin(), Point::new(540.0, 0.0), "the copy moves along one axis"); + assert_eq!(doc.world_bounds(k.r).unwrap().origin(), Point::new(500.0, 0.0), "the original stays"); +} + +#[test] +fn shift_click_takes_a_layer_out_where_nothing_can_move() { + let mut s = Session::new(); + let k = scene(&mut s); + // A version being viewed is read-only: the press can't start a move. + let v = ex(&mut s, "file.saveVersion", json!({"name": "v"})); + ex(&mut s, "version.preview", json!({"id": v["id"]})); + ex(&mut s, "select.set", json!({"ids": [k.r, k.empty]})); + assert!(s.pointer(&ev(PointerKind::Down, 520.0, 20.0, SHIFT)).is_err(), "a version being viewed can't be edited"); + s.pointer(&ev(PointerKind::Up, 520.0, 20.0, SHIFT)).unwrap(); + assert_eq!(s.selection(), vec![k.empty], "Shift-click still takes the layer out"); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 3d3b9acb..d074052f 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -344,6 +344,9 @@ pub(crate) enum Gesture { targets: Targets, duplicate: bool, moved: bool, + /// A Shift-press on this selected layer: it leaves the selection on release, unless the + /// press became a drag. + deselect: Option, }, Resize { start: Point, @@ -1194,15 +1197,15 @@ impl Session { Some(target) => { // Clicking inside the current selection's box keeps it (drag them all). let in_sel_box = sel.len() > 1 && doc.bounds_of(&sel).is_some_and(|b| b.contains(ev.p())) && !ev.shift; + // Shift on a selected layer takes it out on release if the press doesn't become a + // drag; a drag moves the selection with it. Where no drag can start, it goes now. + let deselect = (ev.shift && sel.contains(&target)).then_some(target); + let without_target = || sel.iter().copied().filter(|x| *x != target).collect::>(); let new_sel = if in_sel_box && (sel.contains(&target) || sel.iter().any(|s| doc.is_ancestor(*s, target))) { sel.clone() - } else if ev.shift { + } else if ev.shift && deselect.is_none() { let mut s2 = sel.clone(); - if let Some(pos) = s2.iter().position(|x| *x == target) { - s2.remove(pos); - } else { - s2.push(target); - } + s2.push(target); s2 } else if sel.contains(&target) { sel.clone() @@ -1210,12 +1213,12 @@ impl Session { vec![target] }; self.set_selection(new_sel.clone()); - if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) { - return Ok(()); - } // Locked layers selected in the layers panel stay put. let ids: Vec = roots(&doc, &new_sel).into_iter().filter(|i| !locked_in(&doc, *i)).collect(); - if ids.is_empty() { + if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) || ids.is_empty() { + if deselect.is_some() { + self.set_selection(without_target()); + } return Ok(()); } let mut targets = Targets::gather(&doc, page, &ids); @@ -1225,8 +1228,13 @@ impl Session { { targets.add_box(b); } - self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" })?; - self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false }; + if let Err(e) = self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" }) { + if deselect.is_some() { + self.set_selection(without_target()); + } + return Err(e); + } + self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false, deselect }; self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default }; } None => { @@ -1735,7 +1743,7 @@ impl Session { self.tool.overlay.drop_cell = None; let drop_target = self.tool.overlay.drop_target.take(); match g { - Gesture::Move { ids, moved, duplicate, start, .. } => { + Gesture::Move { ids, moved, duplicate, start, deselect, .. } => { if moved { // A free move lands in the frame under the pointer (or the page) where it was // dropped; layers in an auto layout frame already sit in their slot. @@ -1777,6 +1785,10 @@ impl Session { } } else { self.cancel_interaction()?; + if let Some(d) = deselect { + let sel = self.selection().into_iter().filter(|x| *x != d).collect(); + self.set_selection(sel); + } if !(ev.shift || ev.cmd || duplicate) { self.widget_click(start, ev.tol() * 2.0)?; } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 62001715..a8dfab69 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -48,7 +48,7 @@ interaction feel not yet compared. | Item | Status | Notes | |---|---|---| -| Click, Shift-click, marquee, Shift-marquee toggle | done | a marquee selects what it touches; from a top-level frame's background, the layers inside it; locked and hidden layers are skipped (`selection_behaviour_tests`) | +| Click, Shift-click, marquee, Shift-marquee toggle | done | a marquee selects what it touches; from a top-level frame's background, the layers inside it; locked and hidden layers are skipped; Shift-click takes a selected layer out on release, so a Shift-drag on it moves the selection along one axis instead (`selection_behaviour_tests`) | | Deep select (Cmd/Ctrl-click, Cmd/Ctrl-marquee) | done | | | Select layer menu (every layer under the click) | done | | | Enter / Shift+Enter (children / parent), Tab / Shift+Tab (siblings) | done | Tab renames the next layer while renaming | From d1d8c3f652cea20bee02bdbc3ded9a2f5362ba08 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 16 Oct 2026 12:08:58 -0400 Subject: [PATCH 041/118] ci: standardize FreeBSD workflow on shared FreeBSD 15.1 action (#640) * ci: standardize FreeBSD workflow on shared FreeBSD 15.1 action * ui: widen About window so contributor table fits with Inter font --- .github/workflows/freebsd.yml | 1 - crates/ui-egui/src/dialogs.rs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index ca0cd49f..b01af052 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -31,6 +31,5 @@ jobs: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: - freebsd-release: "14.3" run-package-check: true craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 236f8e0d..6e0ee3a6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -84,7 +84,7 @@ impl AboutTab { } /// The About window's width, the same on every tab (the Contributors table needs the room). -pub const ABOUT_WIDTH: f32 = 640.0; +pub const ABOUT_WIDTH: f32 = 660.0; /// The About window's tab row, drawn like the Design / Prototype tabs of the right panel. /// A row of tabs (the About window's, the shortcuts panel's): the current one highlighted. From 40955aae7c97d48667c2a114bf31a260a257025c Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Fri, 16 Oct 2026 23:09:59 +0700 Subject: [PATCH 042/118] fix(ui): Cmd/Ctrl+V pastes an image copied in another app (#663) egui-winit swallows the Cmd/Ctrl+V press and sends a paste event only when the clipboard holds text, so with an image on the clipboard only the key's release arrives and the image fallback, which waited for the press, never ran. Paste from a V release whose press never came and brought no paste event; the press and paste event are tracked from the raw events at the start of each frame. On Wayland arboard read the X11 clipboard, which the compositor does not fill for a native Wayland window, so every system paste found it empty. Enable arboard's wayland-data-control: it uses the Wayland clipboard when WAYLAND_DISPLAY is set and falls back to X11 otherwise. Right-click > Paste here now follows the same newest-wins rule, so a newer image beats copied layers there too, and lands at the point. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 66 +++++++ apps/figcraft/Cargo.toml | 2 +- crates/ui-egui/src/lib.rs | 11 +- crates/ui-egui/src/menus.rs | 23 +-- crates/ui-egui/src/paste_image_tests.rs | 234 ++++++++++++++++++++++++ crates/ui-egui/src/shortcuts.rs | 44 ++++- 6 files changed, 360 insertions(+), 20 deletions(-) create mode 100644 crates/ui-egui/src/paste_image_tests.rs diff --git a/Cargo.lock b/Cargo.lock index aead39d1..bc60687e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -259,6 +259,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -1734,6 +1735,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -2820,6 +2827,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3204,6 +3220,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "parking" version = "2.2.1" @@ -3264,6 +3290,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.13.1" @@ -4574,6 +4611,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -5726,6 +5774,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "write-fonts" version = "0.48.1" diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index bafb6da1..fcc5d03a 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -21,7 +21,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", " serde_json = { workspace = true } log = { workspace = true } rfd = { workspace = true } -arboard = { workspace = true, features = ["image-data"] } +arboard = { workspace = true, features = ["image-data", "wayland-data-control"] } webbrowser = { workspace = true } image = { workspace = true } env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20a8a77c..bbc61149 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -142,6 +142,8 @@ mod panel_reorder_tests; #[cfg(test)] mod panel_width_tests; #[cfg(test)] +mod paste_image_tests; +#[cfg(test)] mod pen_mode_tests; #[cfg(test)] mod perf_tests; @@ -494,6 +496,10 @@ pub struct UiState { /// What FigCraft last put on the system clipboard: when the clipboard still holds it, a paste /// uses our copied layers; anything else there (an image, SVG, text) came from another app. pub last_copy: Option, + /// Since the last V key release: whether its press arrived, and whether a paste event did + /// (`shortcuts::swallowed_paste`). + pub v_pressed: bool, + pub paste_event: bool, /// When (UI clock, seconds) open documents next get an autosave checkpoint. pub next_autosave: f64, /// With auto-save on: when (UI clock, seconds) files with unsaved changes are next saved, and @@ -587,6 +593,8 @@ impl Default for UiState { dev: dev_mode::DevUi::default(), last_copy: None, + v_pressed: false, + paste_event: false, next_autosave: 0.0, auto_save_at: None, auto_saving: Vec::new(), @@ -1283,6 +1291,7 @@ impl FigApp { // even if it lets go during the frame (Enter commits it), or as it began (egui drops the // focus on Escape before the frame, which cancels the field's edit and nothing else). let typing = std::mem::take(&mut self.field_focus) || a11y::field_focused(&ctx); + let paste_release = shortcuts::swallowed_paste(self, &ctx); // A dialog that closed acts first, in the window it was opened from. self.poll_dialog(&ctx); // Control requests run first so their results reflect this frame. @@ -1398,7 +1407,7 @@ impl FigApp { branch_ui::keys(self, &ctx); live::dialog_keys(self, &ctx); dialogs::keys(self, &ctx); - shortcuts::handle(self, &ctx, typing); + shortcuts::handle(self, &ctx, typing, paste_release); menus::popups(self, &ctx); variable_ui::picker(self, &ctx); style_picker::show(self, &ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b9d3299a..fcbc1dad 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -32,17 +32,18 @@ pub const UI_COMMANDS: &[&str] = &[ /// Run a menu item: some open dialogs or use platform services, the rest go to the engine. /// Paste: the system clipboard wins when it holds something newer than our own copy (an image, SVG /// or text from another app); otherwise our copied layers. `event_text` is the text the platform -/// handed over with the paste event (the browser has no other way to read it). -pub fn paste(app: &mut FigApp, event_text: Option<&str>) { +/// handed over with the paste event (the browser has no other way to read it). `at`: the page +/// point to paste at (Paste here); `None` pastes in the middle of the view. +pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) { let sys_text = app.services.clipboard_get_text().or_else(|| event_text.map(str::to_string)).filter(|t| !t.is_empty()); let ours = sys_text.is_some() && sys_text == app.ui.last_copy; - let (cx, cy) = app.session.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0)); + let (cx, cy) = at.or_else(|| app.session.ds().ok().map(|d| (d.view.cx, d.view.cy))).unwrap_or((0.0, 0.0)); if !ours { if let Some((png, w, h)) = clipboard_png(app) { use base64::Engine as _; let b64 = base64::engine::general_purpose::STANDARD.encode(png); - // Centred in the view. - let (x, y) = ((cx - f64::from(w) / 2.0).round(), (cy - f64::from(h) / 2.0).round()); + // Centred in the view; Paste here puts its top-left at the point, as it does layers. + let (x, y) = if at.is_some() { (cx, cy) } else { ((cx - f64::from(w) / 2.0).round(), (cy - f64::from(h) / 2.0).round()) }; app.run("file.import", json!({"data": b64, "name": "Pasted image.png", "x": x, "y": y})); return; } @@ -54,14 +55,14 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>) { } else if figcraft_engine::looks_like_svg(&text) { use base64::Engine as _; let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); - app.run("file.import", json!({"data": b64, "name": "Pasted SVG.svg", "x": cx, "y": cy, "center": true})); + app.run("file.import", json!({"data": b64, "name": "Pasted SVG.svg", "x": cx, "y": cy, "center": at.is_none()})); } else { app.run("text.create", json!({"text": text, "x": cx, "y": cy})); } return; } } - app.run("edit.paste", json!({})); + app.run("edit.paste", at.map_or(json!({}), |(x, y)| json!({"x": x, "y": y}))); } /// The system clipboard's image as PNG bytes, with its size. @@ -143,7 +144,7 @@ pub fn invoke(app: &mut FigApp, id: &str) { app.services.clipboard_set_text(&text); app.ui.last_copy = Some(text); } - "edit.paste" => paste(app, None), + "edit.paste" => paste(app, None, None), "file.close" if app.session.ds().is_ok_and(|d| d.is_dirty() && d.branch_of.is_none()) => { let save = pretty_shortcut(crate::shortcuts::shortcut("file.save").unwrap_or("")); app.toast(trf( @@ -532,11 +533,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { if item(ui, label, sc, available(id), false, &t) { if *id == "edit.paste" { let p = app.to_page(pos); - if app.session.clipboard.nodes.is_empty() { - invoke(app, id); - } else { - app.run("edit.paste", json!({"x": p.x, "y": p.y})); - } + paste(app, None, Some((p.x, p.y))); } else { invoke(app, id); } diff --git a/crates/ui-egui/src/paste_image_tests.rs b/crates/ui-egui/src/paste_image_tests.rs new file mode 100644 index 00000000..6e202df5 --- /dev/null +++ b/crates/ui-egui/src/paste_image_tests.rs @@ -0,0 +1,234 @@ +//! Pasting an image copied in another app (#663). On the desktop, egui-winit swallows Cmd/Ctrl+V's +//! press and sends a paste event only when the clipboard holds text: an image leaves just the +//! key's release. The browser sends the press and a paste event (`web_clipboard_tests`). + +use std::cell::RefCell; +use std::rc::Rc; + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, Shape, vec2}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::{FigApp, Services}; + +#[derive(Default)] +struct Clip { + text: Option, + image: Option<(u32, u32, Vec)>, +} + +/// The desktop clipboard. +struct Desktop(Rc>); + +impl Services for Desktop { + fn clipboard_set_text(&mut self, s: &str) { + *self.0.borrow_mut() = Clip { text: Some(s.into()), image: None }; + } + fn clipboard_get_text(&mut self) -> Option { + self.0.borrow().text.clone() + } + fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec)> { + self.0.borrow().image.clone() + } +} + +struct Win { + ctx: egui::Context, + app: FigApp, + time: f64, + clip: Rc>, + shapes: Vec, +} + +impl Win { + fn new() -> Self { + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); + let ctx = egui::Context::default(); + let clip = Rc::new(RefCell::new(Clip::default())); + let app = FigApp::new(s, Box::new(Desktop(clip.clone()))); + let mut w = Self { ctx, app, time: 0.0, clip, shapes: Vec::new() }; + for _ in 0..3 { + w.frame(vec![]); + } + w + } + + fn frame(&mut self, events: Vec) { + self.frame_with(events, false); + } + + /// `take_v`: something ahead of the app (a prototype's key trigger) consumes a V press. + fn frame_with(&mut self, events: Vec, take_v: bool) { + self.time += 1.0 / 60.0; + let input = + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() }; + let app = &mut self.app; + let mut out = self.ctx.run_ui(input, |ui| { + if take_v { + ui.ctx().input_mut(|i| i.consume_key(Modifiers::NONE, Key::V)); + } + app.ui(ui); + }); + out.textures_delta.clear(); + self.shapes = out.shapes.into_iter().map(|s| s.shape).collect(); + } + + fn copy_image_elsewhere(&mut self) { + *self.clip.borrow_mut() = Clip { text: None, image: Some((2, 2, vec![255; 16])) }; + } + + /// Cmd/Ctrl+V as egui-winit delivers it: the press brings a paste event when the clipboard + /// holds text and nothing otherwise; the release comes as a key event. + fn cmd_v(&mut self, m: Modifiers) { + let text = self.clip.borrow().text.clone().filter(|t| !t.is_empty()); + self.frame(text.map(Event::Paste).into_iter().collect()); + self.frame(vec![key(Key::V, false, m)]); + self.frame(vec![]); + } + + fn named(&self, name: &str) -> usize { + self.app.session.doc().unwrap().nodes().filter(|n| n.name == name).count() + } + + fn texts(&self) -> usize { + self.app.session.doc().unwrap().nodes().filter(|n| matches!(n.kind, figcraft_doc::NodeKind::Text(_))).count() + } + + fn rects(&self) -> usize { + self.named("Rectangle") + } +} + +const CMD: Modifiers = Modifiers::COMMAND.plus(Modifiers::CTRL); + +fn key(key: Key, pressed: bool, modifiers: Modifiers) -> Event { + Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers } +} + +#[test] +fn cmd_v_pastes_an_image_copied_elsewhere() { + let mut w = Win::new(); + w.copy_image_elsewhere(); + w.cmd_v(CMD); + assert_eq!(w.named("Pasted image"), 1, "the image was pasted from the key's release"); + w.cmd_v(CMD); + assert_eq!(w.named("Pasted image"), 2, "and again"); +} + +#[test] +fn cmd_released_before_v_still_pastes_the_image() { + let mut w = Win::new(); + w.copy_image_elsewhere(); + w.cmd_v(Modifiers::NONE); + assert_eq!(w.named("Pasted image"), 1); +} + +#[test] +fn text_pastes_once_from_its_paste_event() { + let mut w = Win::new(); + *w.clip.borrow_mut() = Clip { text: Some("hello from another app".into()), image: None }; + w.cmd_v(CMD); + assert_eq!(w.texts(), 1, "the paste event pasted the text, the release nothing more"); + assert_eq!(w.rects(), 1); +} + +#[test] +fn text_pastes_once_when_another_key_is_let_go_first() { + let mut w = Win::new(); + *w.clip.borrow_mut() = Clip { text: Some("hello".into()), image: None }; + // Cmd+A then Cmd+V typed fast: A comes up after V went down. + w.frame(vec![key(Key::A, true, CMD)]); + w.frame(vec![Event::Paste("hello".into())]); + w.frame(vec![key(Key::A, false, CMD)]); + w.frame(vec![key(Key::V, false, CMD)]); + w.frame(vec![]); + assert_eq!(w.texts(), 1); + assert_eq!(w.rects(), 1); +} + +#[test] +fn a_v_press_consumed_elsewhere_does_not_paste_on_release() { + let mut w = Win::new(); + w.copy_image_elsewhere(); + w.frame_with(vec![key(Key::V, true, Modifiers::NONE)], true); + w.frame(vec![key(Key::V, false, Modifiers::NONE)]); + w.frame(vec![]); + assert_eq!(w.named("Pasted image"), 0); +} + +#[test] +fn the_browser_sequence_pastes_once() { + let mut w = Win::new(); + w.copy_image_elsewhere(); + w.frame(vec![key(Key::V, true, CMD)]); + w.frame(vec![key(Key::V, false, CMD)]); + w.frame(vec![]); + assert_eq!(w.named("Pasted image"), 1); +} + +#[test] +fn v_alone_and_other_paste_commands_paste_nothing() { + let mut w = Win::new(); + w.copy_image_elsewhere(); + w.frame(vec![key(Key::V, true, Modifiers::NONE)]); + w.frame(vec![key(Key::V, false, Modifiers::NONE)]); + w.cmd_v(CMD.plus(Modifiers::SHIFT)); + w.cmd_v(CMD.plus(Modifiers::ALT)); + assert_eq!(w.named("Pasted image"), 0); +} + +#[test] +fn a_paste_event_from_another_key_does_not_block_the_next_cmd_v() { + let mut w = Win::new(); + *w.clip.borrow_mut() = Clip { text: Some("text".into()), image: None }; + // Shift+Insert on Windows. + w.frame(vec![Event::Paste("text".into())]); + w.frame(vec![key(Key::Insert, false, Modifiers::SHIFT)]); + w.copy_image_elsewhere(); + w.cmd_v(CMD); + assert_eq!(w.named("Pasted image"), 1); +} + +#[test] +fn a_v_press_lost_to_another_window_does_not_block_the_next_cmd_v() { + let mut w = Win::new(); + w.copy_image_elsewhere(); + w.frame(vec![key(Key::V, true, Modifiers::NONE), Event::WindowFocused(false)]); + w.frame(vec![Event::WindowFocused(true)]); + w.cmd_v(CMD); + assert_eq!(w.named("Pasted image"), 1); +} + +#[test] +fn paste_here_prefers_a_newer_image_over_copied_layers() { + let mut w = Win::new(); + w.app.session.execute("select.all", &json!({})).unwrap(); + crate::menus::invoke(&mut w.app, "edit.copy"); + assert!(!w.app.session.clipboard.nodes.is_empty(), "layers copied"); + w.app.session.execute("select.none", &json!({})).unwrap(); + w.copy_image_elsewhere(); + let at = Pos2::new(900.0, 600.0); + for pressed in [true, false] { + w.frame(vec![ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }, + ]); + } + w.frame(vec![]); + let item = crate::live_tests::text_center(&w.shapes, "Paste here"); + for pressed in [true, false] { + w.frame(vec![ + Event::PointerMoved(item), + Event::PointerButton { pos: item, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }, + ]); + } + w.frame(vec![]); + assert_eq!(w.named("Pasted image"), 1, "the newer image was pasted"); + assert_eq!(w.rects(), 1, "not the copied rectangle"); + let p = w.app.to_page(at); + let doc = w.app.session.doc().unwrap(); + let img = doc.nodes().find(|n| n.name == "Pasted image").unwrap(); + let b = doc.world_bounds(img.id).unwrap(); + assert!((b.x0 - p.x).abs() < 0.01 && (b.y0 - p.y).abs() < 0.01, "top-left where the menu opened: {b:?} vs {p:?}"); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index d5bfb156..1ac2824f 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -94,7 +94,8 @@ fn cached_tip(label: &'static str, key: Option<&'static str>) -> &'static str { /// Run the shortcuts this frame's keys press. `typing`: a field had the keyboard when the frame /// began. -pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool) { +/// `paste_release`: this frame released a Cmd/Ctrl+V the shell swallowed ([`swallowed_paste`]). +pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, paste_release: bool) { note_platform(ctx); // Text fields in panels own the keyboard (also the key that ends their editing). if typing || crate::a11y::field_focused(ctx) || app.ui.palette.is_some() { @@ -109,18 +110,19 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool) { text_keys(app, ctx, &events); return; } - // An image-only clipboard brings no paste event, just the key: paste from the key then. + // An image-only clipboard brings no paste event: paste from the key then. The browser sends + // the press; the desktop shell swallows it and sends only the release. let paste_event = events.iter().any(|e| matches!(e, Event::Paste(_))); let paste_key = events.iter().any(|e| matches!(e, Event::Key { key: Key::V, pressed: true, repeat: false, modifiers, .. } if modifiers.command && !modifiers.shift && !modifiers.alt)); - if paste_key && !paste_event { - crate::menus::paste(app, None); + if (paste_key && !paste_event) || paste_release { + crate::menus::paste(app, None, None); } let now = ctx.input(|i| i.time); for ev in &events { match ev { Event::Copy => crate::menus::invoke(app, "edit.copy"), Event::Cut => crate::menus::invoke(app, "edit.cut"), - Event::Paste(text) => crate::menus::paste(app, Some(text)), + Event::Paste(text) => crate::menus::paste(app, Some(text), None), Event::Key { key, physical_key, pressed: true, modifiers, repeat } if key_event(app, *key, *physical_key, *modifiers, *repeat, now, ctx) => { @@ -131,6 +133,38 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool) { } } +/// Whether this frame releases a Cmd/Ctrl+V whose press never arrived and that brought no paste +/// event: egui-winit swallows the press and sends a paste event only when the clipboard holds +/// text, so an image on the clipboard leaves just this release. Call at the start of every frame: +/// it reads the raw events, which keys consumed by panels or the presenter still carry. +pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> bool { + let ui = &mut app.ui; + let mut paste = false; + ctx.input(|i| { + for ev in &i.raw.events { + match ev { + Event::Paste(_) => ui.paste_event = true, + Event::Key { key: Key::V, pressed: true, .. } => ui.v_pressed = true, + Event::Key { key: Key::V, pressed: false, modifiers, .. } => { + // Shift or Alt with it is another command (paste over, paste properties). + paste = !ui.v_pressed && !ui.paste_event && !modifiers.shift && !modifiers.alt; + ui.v_pressed = false; + ui.paste_event = false; + } + // The other keys that paste (Shift+Insert, a Paste key) end their paste event. + Event::Key { key: Key::Insert | Key::Paste, pressed: false, .. } => ui.paste_event = false, + // Keys let go in another window never come back as releases. + Event::WindowFocused(false) => { + ui.v_pressed = false; + ui.paste_event = false; + } + _ => {} + } + } + }); + paste +} + /// Run the command a shortcut is bound to, with the keyboard's own twists (Escape steps out /// before it deselects, Delete ends a pen path, Enter edits the selected text or vector). pub fn run_binding(app: &mut FigApp, id: &str) { From 569a3b3669c409a0e90dcad6b2424e1134b35a6c Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Fri, 16 Oct 2026 23:11:00 +0700 Subject: [PATCH 043/118] fix(engine): a Shift-click on a selected widget doesn't also click it (#498) With Shift let go before the button, the release took the layer out of the selection and also ran the widget's click. The take-out now skips the widget click, as a Shift-click always has. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/tools.rs | 2 +- crates/engine/src/widgets_tests.rs | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index d074052f..dbda1894 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1789,7 +1789,7 @@ impl Session { let sel = self.selection().into_iter().filter(|x| *x != d).collect(); self.set_selection(sel); } - if !(ev.shift || ev.cmd || duplicate) { + if deselect.is_none() && !(ev.shift || ev.cmd || duplicate) { self.widget_click(start, ev.tol() * 2.0)?; } } diff --git a/crates/engine/src/widgets_tests.rs b/crates/engine/src/widgets_tests.rs index 7079905f..f228d35c 100644 --- a/crates/engine/src/widgets_tests.rs +++ b/crates/engine/src/widgets_tests.rs @@ -248,6 +248,19 @@ fn clicking_a_widget_on_the_canvas_calls_its_plugin_back() { assert!(after.x - before.x > 30.0 && state(&mut s, w) == json!({"n": 2}), "{before:?} → {after:?}"); } +#[test] +fn a_shift_click_on_a_selected_widget_only_deselects_it() { + let mut s = session(); + let (w, _) = insert(&mut s); + let target = s.doc().unwrap().world_bounds(node(&s, w).children[1]).unwrap().center(); + let mut down = PointerEvent::at(PointerKind::Down, target.x, target.y, 1.0); + down.shift = true; + // Shift let go before the button: still a Shift-click, not a click on the widget. + s.pointer(&down).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, target.x, target.y, 1.0)).unwrap(); + assert_eq!((s.selection(), state(&mut s, w)), (vec![], json!({"n": 1}))); +} + #[test] fn bad_widgets_and_clicks_are_refused_and_roll_back() { let mut s = session(); From 60fa813eb25a0ad4f8de6dfadbfbb61a1e3f30b0 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Sat, 17 Oct 2026 00:27:34 +0700 Subject: [PATCH 044/118] fix(format): numbered lists from .fig files keep their start number (#686) Figma stores where a numbered list starts counting on each text line (listStartOffset; a list shown as "2." has offset 1). The import dropped it, so every list counted from 1. A paragraph's list settings now carry a start number (written only when set, so older files open and save unchanged). Numbering counts on from it where a list starts; items that go on counting ignore it. The .fig import reads it, the .fig export writes it on a list's first line, and plain paragraphs drop it. Toggling a list off ignores differing starts. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/doc/src/text.rs | 46 ++++++++++-- crates/engine/src/text_tail_tests.rs | 21 +++++- crates/format/src/fig/export.rs | 4 +- crates/format/src/fig/export_tests.rs | 2 +- crates/format/src/fig/map.rs | 12 ++- crates/format/src/fig/tests.rs | 3 + crates/format/src/fig/text_list_tests.rs | 94 ++++++++++++++++++++++++ crates/format/src/rest/text.rs | 5 +- crates/text/src/layout.rs | 7 +- crates/text/src/tail_tests.rs | 27 ++++++- docs/file-format-parity.md | 8 +- 11 files changed, 204 insertions(+), 25 deletions(-) create mode 100644 crates/format/src/fig/text_list_tests.rs diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index b294151b..161b207d 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -215,6 +215,10 @@ pub struct ParaList { pub list: ListStyle, /// Nesting level (0 = outermost; plain paragraphs are always 0). pub indent: u8, + /// A numbered list that starts counting at this paragraph counts from `start + 1` (items + /// that go on counting ignore it; plain paragraphs are always 0). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub start: u32, } /// Character style. In runs, every field is an override; `None` means "inherit the base". @@ -637,13 +641,13 @@ impl TextData { /// Paragraph `p`'s list settings. pub fn para_list(&self, p: usize) -> ParaList { - self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, indent: 0 }) + self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, ..ParaList::default() }) } /// Spell out every paragraph's list settings in `paragraphs` (and drop extra entries). fn fill_paragraphs(&mut self) { let n = self.paragraph_count(); - let fill = ParaList { list: self.list, indent: 0 }; + let fill = ParaList { list: self.list, ..ParaList::default() }; self.paragraphs.resize(n, fill); } @@ -657,17 +661,20 @@ impl TextData { } /// Apply `f` to the list settings of the paragraphs chars `start..end` touch (plain - /// paragraphs stay at level 0, levels stop at [`MAX_LIST_INDENT`]); one style for all with - /// no nesting folds back into `list`. + /// paragraphs stay at level 0 with no start, levels stop at [`MAX_LIST_INDENT`]); one style + /// for all with no nesting or start folds back into `list`. pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) { let (a, b) = self.para_span(start, end); self.fill_paragraphs(); for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) { f(pl); - pl.indent = if pl.list == ListStyle::None { 0 } else { pl.indent.min(MAX_LIST_INDENT) }; + if pl.list == ListStyle::None { + *pl = ParaList::default(); + } + pl.indent = pl.indent.min(MAX_LIST_INDENT); } if let Some(first) = self.paragraphs.first().copied() - && first.indent == 0 + && first == (ParaList { list: first.list, ..ParaList::default() }) && self.paragraphs.iter().all(|p| *p == first) { self.list = first.list; @@ -675,11 +682,12 @@ impl TextData { } } - /// The list settings shared by the paragraphs chars `start..end` touch, or None when they differ. + /// The list style and level shared by the paragraphs chars `start..end` touch (the first + /// one's settings), or None when they differ. Start numbers don't count. pub fn common_para_list(&self, start: usize, end: usize) -> Option { let (a, b) = self.para_span(start, end); let first = self.para_list(a); - (a..=b).all(|p| self.para_list(p) == first).then_some(first) + (a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first) } /// Link chars `start..end` (all of them when the range is empty or the whole text) to text @@ -782,4 +790,26 @@ mod case_tests { assert_eq!(st.variations.keys().collect::>(), ["wdth"]); assert!(valid_axis_tag("wght") && !valid_axis_tag("weight")); } + + #[test] + fn list_starts_load_from_old_files_and_follow_edits() { + use ListStyle::{None as P, Numbered as N}; + // Files written before start numbers load with none, and write the same bytes. + let old = r#"{"list":"numbered","indent":1}"#; + let pl: ParaList = serde_json::from_str(old).unwrap(); + assert_eq!(pl, ParaList { list: N, indent: 1, start: 0 }); + assert_eq!(serde_json::to_string(&pl).unwrap(), old); + // One numbered paragraph with a start keeps it (it doesn't fold into `list`). + let mut t = TextData { characters: "Next".into(), ..Default::default() }; + t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start: 1 }); + assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start: 1 }); + // Enter copies it to the new item; different starts don't make the list mixed. + t.replace_range(4, 4, "\nMore"); + assert_eq!(t.para_list(1).start, 1); + t.set_para_lists(5, 5, |p| p.start = 0); + assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N)); + // A plain paragraph has none. + t.set_para_lists(0, 0, |p| p.list = P); + assert_eq!(t.para_list(0), ParaList::default()); + } } diff --git a/crates/engine/src/text_tail_tests.rs b/crates/engine/src/text_tail_tests.rs index b951bfc7..687c67b8 100644 --- a/crates/engine/src/text_tail_tests.rs +++ b/crates/engine/src/text_tail_tests.rs @@ -57,6 +57,21 @@ fn list_buttons_act_on_the_selected_paragraphs() { assert_eq!(data(&s, t).list, P, "toggled off"); } +#[test] +fn a_list_with_a_start_number_toggles_off() { + // An imported list that starts at 2 has the start on its first item only; it is still one + // numbered list, so the button turns it back to plain (#686). + let mut s = Session::new(); + let t = text(&mut s, "two\nthree\nfour"); + if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind { + td.paragraphs = + vec![ParaList { list: N, indent: 0, start: 1 }, ParaList { list: N, indent: 0, start: 0 }, ParaList { list: N, indent: 0, start: 0 }]; + } + s.execute("text.numberList", &json!({"ids": [t]})).unwrap(); + assert_eq!(lists(&s, t), [(P, 0); 3]); + assert_eq!(data(&s, t).para_list(0).start, 0, "a plain paragraph has no start"); +} + #[test] fn typed_list_starts() { // Typing "- " or "* " at the start of a paragraph starts a bulleted list, "1. " a numbered one @@ -117,7 +132,7 @@ fn list_keys_at_list_items() { for (chars, before, caret, k, shift, after, want, why) in cases { let mut s = Session::new(); let t = text(&mut s, chars); - let ps: Vec = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent }).collect(); + let ps: Vec = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start: 0 }).collect(); if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind { td.paragraphs = ps; } @@ -141,7 +156,7 @@ fn paragraph_lists_follow_edits() { // A paragraph's list stays with it as text is typed, pasted and deleted: new paragraphs copy // the one they split from; joined paragraphs keep the first one's. let mut t = TextData::new("ab\ncd\nef"); - t.paragraphs = vec![ParaList { list: B, indent: 1 }, ParaList::default(), ParaList { list: N, indent: 0 }]; + t.paragraphs = vec![ParaList { list: B, indent: 1, start: 0 }, ParaList::default(), ParaList { list: N, indent: 0, start: 0 }]; let get = |t: &TextData| (0..t.paragraph_count()).map(|p| (t.para_list(p).list, t.para_list(p).indent)).collect::>(); t.replace_range(1, 1, "x\ny"); assert_eq!(get(&t), [(B, 1), (B, 1), (P, 0), (N, 0)], "a pasted newline: a new item like it"); @@ -156,7 +171,7 @@ fn paragraph_lists_follow_edits() { (seed >> 33) as usize % n.max(1) }; let mut t = TextData::new("a\nb\nc\nd"); - t.paragraphs = vec![ParaList { list: B, indent: 2 }; 4]; + t.paragraphs = vec![ParaList { list: B, indent: 2, start: 0 }; 4]; for _ in 0..500 { let n = t.char_count(); let (a, b) = (rnd(n + 2), rnd(n + 2)); diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index bfd0b615..cb2b8bff 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1574,12 +1574,14 @@ impl<'d> Exporter<'d> { }; let listed = *l != ListStyle::None; let first = listed && (i == 0 || lists.get(i - 1) != Some(l)); + // Where a numbered list starts counting, less one. + let start = if first && *l == ListStyle::Numbered { td.para_list(i).start } else { 0 }; o(vec![ ("lineType", e(ty)), ("styleId", int(0)), ("indentationLevel", int(i64::from(listed))), ("sourceDirectionality", e("AUTO")), - ("listStartOffset", int(0)), + ("listStartOffset", int(i64::from(start))), ("isFirstLineOfList", b(first)), ]) }; diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 67215b0c..53cfaf91 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -680,7 +680,7 @@ fn normalize(n: &mut Node, ix: &dyn Fn(NodeId) -> NodeId) { for r in &mut td.runs { px(&mut r.style); } - td.paragraphs = (0..td.paragraph_count()).map(|p| ParaList { list: td.para_list(p).list, indent: 0 }).collect(); + td.paragraphs = (0..td.paragraph_count()).map(|p| ParaList { indent: 0, ..td.para_list(p) }).collect(); td.list = ListStyle::None; } NodeKind::Vector { network } => { diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 66a00129..cbde49d8 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -827,10 +827,8 @@ impl<'a> Mapper<'a> { o.insert("characterStyleOverrides".into(), json!(ids)); o.insert("styleOverrideTable".into(), Value::Object(table)); } - let lines: Vec<&str> = td - .get("lines") - .map(Kv::list) - .unwrap_or_default() + let raw = td.get("lines").map(Kv::list).unwrap_or_default(); + let lines: Vec<&str> = raw .iter() .map(|l| match l.get("lineType").and_then(Kv::str) { Some("ORDERED_LIST") => "ORDERED", @@ -841,6 +839,12 @@ impl<'a> Mapper<'a> { if lines.iter().any(|l| *l != "NONE") { o.insert("lineTypes".into(), json!(lines)); } + // Where a numbered list starts counting, less one (not a REST field). + let starts: Vec = + raw.iter().map(|l| num(l, "listStartOffset").filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32)).collect(); + if starts.iter().any(|n| *n > 0) { + o.insert("listStartOffsets".into(), json!(starts)); + } } /// `fillGeometry` / `strokeGeometry` as SVG path data, and star/polygon parameters. diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 19c4fc15..1173dacd 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -873,6 +873,9 @@ fn nested_instances_resized_again_drop_the_layout_derived_for_their_old_size() { #[path = "tail_tests.rs"] mod tail; +#[path = "text_list_tests.rs"] +mod text_list; + /// `.fig` files in the folder `FIGCRAFT_FIG_SAMPLES` names (none when it's unset). pub(super) fn samples() -> Vec { let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return Vec::new() }; diff --git a/crates/format/src/fig/text_list_tests.rs b/crates/format/src/fig/text_list_tests.rs new file mode 100644 index 00000000..8d314ccd --- /dev/null +++ b/crates/format/src/fig/text_list_tests.rs @@ -0,0 +1,94 @@ +//! Numbered lists that start past 1 (#686): each text line says where its list starts counting, +//! less one (`listStartOffset`). Fixtures built in code with the parent module's helpers. + +use figcraft_doc::{ListStyle, NodeKind, ParaList}; + +use super::*; + +const LIST_SCHEMA: &str = " +enum NodeType NONE DOCUMENT CANVAS TEXT +enum LineType PLAIN ORDERED_LIST UNORDERED_LIST +struct GUID uint sessionID, uint localID +struct Vector float x, float y +struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 +message ParentIndex GUID guid, string position +message TextLineData LineType lineType, int indentationLevel, int listStartOffset, bool isFirstLineOfList +message TextData string characters, TextLineData[] lines +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, float fontSize, TextData textData +message Message NodeChange[] nodeChanges +"; + +/// A list line: type, start offset, first of its list. +fn line(ty: &'static str, offset: i64, first: bool) -> Kv<'static> { + let level = i64::from(ty != "PLAIN"); + o(vec![("lineType", e(ty)), ("indentationLevel", Kv::Int(level)), ("listStartOffset", Kv::Int(offset)), ("isFirstLineOfList", Kv::Bool(first))]) +} + +fn text_node(id: u64, name: &str, chars: &str, lines: Vec>) -> Kv<'static> { + let pos = ["!", "\"", "#", "$", "%", "&"][id as usize % 6]; + node( + id, + Some((1, pos)), + "TEXT", + name, + vec![ + ("size", xy(200.0, 60.0)), + ("transform", at(0.0, 0.0)), + ("fontSize", f(16.0)), + ("textData", o(vec![("characters", s(chars)), ("lines", arr(lines))])), + ], + ) +} + +fn starts(doc: &figcraft_doc::Document, name: &str) -> Vec<(ListStyle, u32)> { + let NodeKind::Text(t) = &named(doc, name).kind else { panic!("{name} is no text") }; + (0..t.paragraph_count()).map(|p| t.para_list(p)).map(|p: ParaList| (p.list, p.start)).collect() +} + +#[test] +fn numbered_lists_keep_their_start() { + use ListStyle::{Bulleted as B, None as P, Numbered as N}; + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + text_node(2, "From 1", "Start here", vec![line("ORDERED_LIST", 0, true)]), + text_node(3, "From 2", "Next try this", vec![line("ORDERED_LIST", 1, true)]), + text_node(4, "First only", "a\nb\nc", vec![line("ORDERED_LIST", 3, true), line("ORDERED_LIST", 0, false), line("ORDERED_LIST", 0, false)]), + text_node(5, "Every line", "a\nb\nc", vec![line("ORDERED_LIST", 3, true), line("ORDERED_LIST", 3, false), line("ORDERED_LIST", 3, false)]), + text_node(6, "Mixed", "x\na\nb", vec![line("PLAIN", 0, false), line("ORDERED_LIST", 4, true), line("UNORDERED_LIST", 2, true)]), + text_node(7, "Junk", "a", vec![line("ORDERED_LIST", -5, true)]), + ]; + let sc = Schema::parse(LIST_SCHEMA).unwrap(); + let (doc, warnings) = import(&canvas(&sc, &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(starts(&doc, "From 1"), [(N, 0)]); + assert_eq!(starts(&doc, "From 2"), [(N, 1)], "shows 2."); + assert_eq!(starts(&doc, "First only"), [(N, 3), (N, 0), (N, 0)]); + assert_eq!(starts(&doc, "Every line"), [(N, 3), (N, 3), (N, 3)]); + assert_eq!(starts(&doc, "Mixed"), [(P, 0), (N, 4), (B, 2)]); + assert_eq!(starts(&doc, "Junk"), [(N, 0)], "a negative offset counts from 1"); +} + +#[test] +fn the_export_writes_where_a_list_starts() { + let mut doc = figcraft_doc::Document::new("Lists"); + let page = doc.first_page().unwrap(); + let mut td = figcraft_doc::TextData::new("two\nthree\nplain\nsix"); + td.paragraphs = vec![ + ParaList { list: ListStyle::Numbered, indent: 0, start: 1 }, + ParaList { list: ListStyle::Numbered, indent: 0, start: 1 }, + ParaList::default(), + ParaList { list: ListStyle::Numbered, indent: 0, start: 5 }, + ]; + doc.create(page, NodeKind::Text(td), "List", 0.0, 0.0, 200.0, 80.0).unwrap(); + let (bytes, _) = super::super::export(&doc, None).unwrap(); + let a = Archive::open(&bytes).unwrap(); + let (schema, message) = decode_canvas(&a.canvas).unwrap(); + let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); + let text = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("name").and_then(Kv::str) == Some("List")).unwrap(); + let lines = text.get("textData").and_then(|d| d.get("lines")).map(Kv::list).unwrap(); + let offsets: Vec = lines.iter().map(|l| l.get("listStartOffset").and_then(Kv::f64).unwrap()).collect(); + assert_eq!(offsets, [1.0, 0.0, 0.0, 5.0], "on the first line of each list only"); + let (back, _) = import(&bytes, "x").unwrap(); + assert_eq!(starts(&back, "List"), [(ListStyle::Numbered, 1), (ListStyle::Numbered, 0), (ListStyle::None, 0), (ListStyle::Numbered, 5)]); +} diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index c5381bb2..ea66e390 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -210,10 +210,12 @@ fn runs(cx: &mut Ctx, v: &Value, td: &TextData) -> Vec { out } -/// Each paragraph's list type and nesting level from `lineTypes` and `lineIndentations`. +/// Each paragraph's list type, nesting level and start number from `lineTypes`, +/// `lineIndentations` and (from `.fig` files) `listStartOffsets`. fn lists(v: &Value, td: &mut TextData) { let types = list(v, "lineTypes"); let indents = list(v, "lineIndentations"); + let starts = list(v, "listStartOffsets"); td.paragraphs = types .iter() .enumerate() @@ -224,6 +226,7 @@ fn lists(v: &Value, td: &mut TextData) { _ => ListStyle::None, }, indent: indents.get(i).and_then(Value::as_f64).filter(|d| d.is_finite()).map_or(0, |d| d.clamp(0.0, 255.0) as u8), + start: starts.get(i).and_then(Value::as_f64).filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32), }) .collect(); // Clamp the levels and fold one style for all back into the layer's list style. diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 37092da8..5ba5dd48 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -827,8 +827,8 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } /// The markers of a text's paragraphs ("" for plain ones). Numbers count the items of one list at -/// one level, restarting after a plain paragraph, a shallower item or the other kind of list; -/// levels go 1. a. i. and • ◦ ▪, then round again. +/// one level, restarting after a plain paragraph, a shallower item or the other kind of list, +/// from the first item's start number; levels go 1. a. i. and • ◦ ▪, then round again. pub(crate) fn list_markers(lists: &[ParaList]) -> Vec { const BULLETS: [&str; 3] = ["\u{2022}", "\u{25E6}", "\u{25AA}"]; let mut count = [(ListStyle::None, 0usize); figcraft_doc::MAX_LIST_INDENT as usize + 1]; @@ -844,7 +844,8 @@ pub(crate) fn list_markers(lists: &[ParaList]) -> Vec { *c = (ListStyle::None, 0); } let Some(c) = count.get_mut(d) else { return String::new() }; - *c = if c.0 == pl.list { (pl.list, c.1 + 1) } else { (pl.list, 1) }; + let first = usize::try_from(pl.start).unwrap_or(usize::MAX).saturating_add(1); + *c = if c.0 == pl.list { (pl.list, c.1.saturating_add(1)) } else { (pl.list, first) }; match (pl.list, d % 3) { (ListStyle::Numbered, 1) => format!("{}.", alpha(c.1)), (ListStyle::Numbered, 2) => format!("{}.", roman(c.1)), diff --git a/crates/text/src/tail_tests.rs b/crates/text/src/tail_tests.rs index 890b254d..b1854dfd 100644 --- a/crates/text/src/tail_tests.rs +++ b/crates/text/src/tail_tests.rs @@ -43,7 +43,32 @@ fn starts(l: &TextLayout) -> Vec<(f64, Option)> { } fn pl(list: ListStyle, indent: u8) -> ParaList { - ParaList { list, indent } + ParaList { list, indent, start: 0 } +} + +fn from(list: ListStyle, indent: u8, start: u32) -> ParaList { + ParaList { list, indent, start } +} + +#[test] +fn numbered_lists_count_from_their_start() { + use ListStyle::{Bulleted as B, None as P, Numbered as N}; + let cases: Vec<(Vec, Vec<&str>, &str)> = vec![ + (vec![from(N, 0, 1)], vec!["2."], "a one-item list started at 2 (#686)"), + (vec![from(N, 0, 3), pl(N, 0), pl(N, 0)], vec!["4.", "5.", "6."], "the items after the first count on from it"), + (vec![from(N, 0, 3), from(N, 0, 3), from(N, 0, 3)], vec!["4.", "5.", "6."], "items that go on counting ignore their own start"), + (vec![from(N, 0, 1), pl(P, 0), from(N, 0, 1)], vec!["2.", "", "2."], "a list after a plain paragraph starts from its own start"), + (vec![pl(N, 0), from(N, 1, 1), pl(N, 1)], vec!["1.", "b.", "c."], "a nested level starts from its first item's start"), + (vec![from(B, 0, 4), pl(B, 0)], vec!["\u{2022}", "\u{2022}"], "bullets have no number"), + (vec![from(N, 0, 4_000), pl(N, 2)], vec!["4001.", "i."], "big starts"), + ]; + for (lists, want, why) in cases { + assert_eq!(list_markers(&lists), want, "{why}"); + } + // The largest start never overflows, on 32-bit targets too. + let max = list_markers(&[from(N, 0, u32::MAX), pl(N, 0), pl(N, 0)]); + assert_eq!(max.len(), 3); + assert!(max.iter().all(|m| m.starts_with("429496729")), "{max:?}"); } #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d8e51711..1b8cdcc4 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (`.fig` numbered list start numbers (#686); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -17,7 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs and list types (numbered lists keep the number they start at), vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout, constraints, grids, components, sets, instances with overrides, component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | @@ -88,7 +88,8 @@ each. layers sit in it (fill, absolute, min/max sizes); layout grids; export settings; text style (font, with styles under their standard names such as "Semi Bold"; size; line height, a share of the font size as a raw multiplier; letter spacing, case, decoration, links), alignment, auto resize, - truncation, paragraph spacing and indent, list types and styled runs with their fills (indexed + truncation, paragraph spacing and indent, list types (with the number a numbered list starts + at; not yet checked in other software) and styled runs with their fills (indexed by UTF-16 unit); components (description, boolean, text and instance swap properties and the layers bound to them), component sets, instances (their main, property values, what the instance itself changes from its main, such as its size, name and paints, as an override addressed by the @@ -162,6 +163,7 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | `.fig` numbered lists keep the number they start at (#686): a list Figma shows as starting at 2 imported as 1. The import reads each line's start offset, a text keeps it per paragraph (older files open unchanged), numbering counts on from it, and the export writes it on a list's first line. Nested list levels from `.fig` files are still read as the first level. Numbers unchanged | | 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. | | 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Opened `.fig` and file JSON documents link nested instances of variants (and any nested instance under a name of its own) to their main's layers; they stayed unlinked, so a second export lost what they changed. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | | 2026-10-12 | minor | `.fig` export made to open elsewhere (gap 1): the document and its pages say they are visible and opaque (left out, nothing was drawn), the schema in the file uses the format's own names, kinds, field ids and enum values, rectangles are rounded rectangles with four radii, frames and shapes carry their fill outline and plain strokes their stroke outline, text carries its layout settings, font styles use their standard names, line heights that are a share of the font size are raw multipliers, masks keep their type (read back too); the import reads per-side stroke weights whose zero sides are left out. Written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers unchanged | From 4adbd6cee98b80c877ad31de57d3bf54ef13741c Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Sat, 17 Oct 2026 00:35:46 +0700 Subject: [PATCH 045/118] fix(ui): a V held while the window was away doesn't paste on release (#663) winit sends no press for a key held across a focus change (X11's synthetic presses are dropped by egui-winit), so letting go of V after coming back looked like a swallowed Cmd+V. After a focus gain, a V release only pastes with Cmd still held. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/lib.rs | 3 +++ crates/ui-egui/src/paste_image_tests.rs | 24 ++++++++++++++++++++++++ crates/ui-egui/src/shortcuts.rs | 8 ++++++-- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bbc61149..9fbd17cc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -500,6 +500,8 @@ pub struct UiState { /// (`shortcuts::swallowed_paste`). pub v_pressed: bool, pub paste_event: bool, + /// The window got the focus back since the last V release. + pub refocused: bool, /// When (UI clock, seconds) open documents next get an autosave checkpoint. pub next_autosave: f64, /// With auto-save on: when (UI clock, seconds) files with unsaved changes are next saved, and @@ -595,6 +597,7 @@ impl Default for UiState { last_copy: None, v_pressed: false, paste_event: false, + refocused: false, next_autosave: 0.0, auto_save_at: None, auto_saving: Vec::new(), diff --git a/crates/ui-egui/src/paste_image_tests.rs b/crates/ui-egui/src/paste_image_tests.rs index 6e202df5..fa797fb9 100644 --- a/crates/ui-egui/src/paste_image_tests.rs +++ b/crates/ui-egui/src/paste_image_tests.rs @@ -200,6 +200,30 @@ fn a_v_press_lost_to_another_window_does_not_block_the_next_cmd_v() { assert_eq!(w.named("Pasted image"), 1); } +#[test] +fn v_held_while_the_window_was_away_pastes_nothing_on_release() { + let mut w = Win::new(); + w.copy_image_elsewhere(); + // Pressed in another window: only the focus and the release arrive here. + w.frame(vec![Event::WindowFocused(true)]); + w.frame(vec![key(Key::V, false, Modifiers::NONE)]); + w.frame(vec![]); + assert_eq!(w.named("Pasted image"), 0); + w.cmd_v(CMD); + assert_eq!(w.named("Pasted image"), 1, "the next Cmd+V still pastes"); +} + +#[test] +fn cmd_v_in_a_panel_field_does_not_paste_on_the_canvas() { + let mut w = Win::new(); + w.copy_image_elsewhere(); + w.frame(vec![]); + w.app.field_focus = true; + w.frame(vec![key(Key::V, false, CMD)]); + w.frame(vec![]); + assert_eq!(w.named("Pasted image"), 0); +} + #[test] fn paste_here_prefers_a_newer_image_over_copied_layers() { let mut w = Win::new(); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 1ac2824f..f28a2433 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -146,10 +146,13 @@ pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> bool { Event::Paste(_) => ui.paste_event = true, Event::Key { key: Key::V, pressed: true, .. } => ui.v_pressed = true, Event::Key { key: Key::V, pressed: false, modifiers, .. } => { - // Shift or Alt with it is another command (paste over, paste properties). - paste = !ui.v_pressed && !ui.paste_event && !modifiers.shift && !modifiers.alt; + // Shift or Alt with it is another command (paste over, paste properties). A V + // held while the window was away comes back as a release alone: without Cmd + // held, that's no paste. + paste = !ui.v_pressed && !ui.paste_event && !modifiers.shift && !modifiers.alt && (modifiers.command || !ui.refocused); ui.v_pressed = false; ui.paste_event = false; + ui.refocused = false; } // The other keys that paste (Shift+Insert, a Paste key) end their paste event. Event::Key { key: Key::Insert | Key::Paste, pressed: false, .. } => ui.paste_event = false, @@ -158,6 +161,7 @@ pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> bool { ui.v_pressed = false; ui.paste_event = false; } + Event::WindowFocused(true) => ui.refocused = true, _ => {} } } From 9aeb9fb75207e615e34a2dffcca778a77088280c Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:58:00 +0200 Subject: [PATCH 046/118] Cap stroke width in the outliner so huge weights don't panic Found while testing crates/engine/src/cmd/props.rs:361 (strokeWeight is only clamped with .max(0.0)); the panic is raised when the renderer draws the stroke (render crate / tiny-skia stroker) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/huge_stroke_tests.rs | 28 ++++++++++++++++++++++++++ crates/engine/src/lib.rs | 2 ++ crates/pathops/src/lib.rs | 18 ++++++++++++++--- 3 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/huge_stroke_tests.rs diff --git a/crates/engine/src/huge_stroke_tests.rs b/crates/engine/src/huge_stroke_tests.rs new file mode 100644 index 00000000..7efabb48 --- /dev/null +++ b/crates/engine/src/huge_stroke_tests.rs @@ -0,0 +1,28 @@ +//! An absurd stroke weight must not take the renderer down with it: saving (thumbnail) and exporting still work. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use figcraft_doc::NodeId; + +fn id(v: Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +#[test] +fn huge_stroke_weight_with_round_caps_still_saves_and_exports() { + for weight in [1e6, 1e7, 1e10, 1e30] { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let v = id(s.execute("vector.create", &json!({"parent": f, "path": "M0 0.8333 L7.5 6.25 L15 0.8333"})).unwrap()); + s.execute("node.set", &json!({"id": v, "props": {"stroke": "#ff0000", "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round"}})) + .unwrap(); + let path = std::env::temp_dir().join(format!("figcraft-huge-stroke-{}-{weight}.figcraft", std::process::id())); + let saved = s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})); + let _ = std::fs::remove_file(&path); + assert!(saved.is_ok(), "saveAs at weight {weight}: {:?}", saved.err()); + let exported = s.execute("file.export", &json!({"ids": [f], "format": "png"})); + assert!(exported.is_ok(), "export at weight {weight}: {:?}", exported.err()); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..a68a78d9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1548,6 +1548,8 @@ mod history_behaviour_tests; #[cfg(test)] mod html_codegen_tests; #[cfg(test)] +mod huge_stroke_tests; +#[cfg(test)] mod import_export_tests; #[cfg(test)] mod inspect_behaviour_tests; diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 553fce04..d4071ca1 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -258,11 +258,16 @@ pub(crate) fn kjoin(j: StrokeJoin) -> Join { } } -/// Plain centred stroke of `path` at `width`. +/// The widest stroke outlined: past this the rasteriser's fixed-point maths overflows and panics, +/// and nothing visible changes (a stroke this wide covers any canvas already). +pub const MAX_STROKE_WIDTH: f64 = 1e6; + +/// Plain centred stroke of `path` at `width` (capped at [`MAX_STROKE_WIDTH`]). pub fn stroke_center(path: &BezPath, width: f64, props: &StrokeProps) -> BezPath { if !(width.is_finite() && width > 0.0 && finite(path)) { return BezPath::new(); } + let width = width.min(MAX_STROKE_WIDTH); let mut style = kurbo::Stroke::new(width) .with_join(kjoin(props.join)) .with_miter_limit(props.miter_limit.clamp(1.0, 100.0)) @@ -284,10 +289,17 @@ pub fn stroke_outline(path: &BezPath, props: &StrokeProps, closed_shape: bool) - /// [`stroke_outline`] of a shape whose stroke follows vector network `net` (`path` is its stroke /// path): points with a cap or join of their own draw them there. pub fn stroke_outline_of(path: &BezPath, props: &StrokeProps, closed_shape: bool, net: Option<&VectorNetwork>) -> BezPath { - let w = props.weight; - if !(w.is_finite() && w > 0.0) { + if !(props.weight.is_finite() && props.weight > 0.0) { return BezPath::new(); } + let capped; + let props = if props.weight > MAX_STROKE_WIDTH { + capped = StrokeProps { weight: MAX_STROKE_WIDTH, ..props.clone() }; + &capped + } else { + props + }; + let w = props.weight; let styled = net.and_then(|n| points::Styled::new(n, props)); // Centred at `width` (twice the weight for Inside/Outside, which clip half of it away). let centred = |width: f64| match (&styled, &props.brush, props.profile()) { From d27f45e06320e642e55324eb9bd01bb352153525 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Sat, 17 Oct 2026 00:58:20 +0700 Subject: [PATCH 047/118] fix(format): write a list's start on every line; report it in get_node (#686) The .fig export now writes a numbered list's start on each of its lines, so a reader that takes it per line or per list counts the same. Bullets no longer keep a start. The field is named start_offset (it holds the offset: 1 shows "2."). get_node reports listStartOffsets. The parity doc lists what's still not read from .fig lists. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/doc/src/text.rs | 30 ++++++++++++++---------- crates/engine/src/inspect.rs | 5 +++- crates/engine/src/text_tail_tests.rs | 15 +++++++----- crates/format/src/fig/export.rs | 21 +++++++++++++---- crates/format/src/fig/text_list_tests.rs | 21 ++++++++--------- crates/format/src/rest/text.rs | 2 +- crates/text/src/layout.rs | 2 +- crates/text/src/tail_tests.rs | 6 ++--- docs/file-format-parity.md | 6 ++--- docs/mcp.md | 3 ++- 10 files changed, 68 insertions(+), 43 deletions(-) diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 161b207d..097dd189 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -215,10 +215,10 @@ pub struct ParaList { pub list: ListStyle, /// Nesting level (0 = outermost; plain paragraphs are always 0). pub indent: u8, - /// A numbered list that starts counting at this paragraph counts from `start + 1` (items - /// that go on counting ignore it; plain paragraphs are always 0). + /// A numbered list that starts counting at this paragraph counts from `start_offset + 1` + /// (items that go on counting ignore it; other paragraphs are always 0). #[serde(skip_serializing_if = "crate::node::is_default")] - pub start: u32, + pub start_offset: u32, } /// Character style. In runs, every field is an override; `None` means "inherit the base". @@ -661,15 +661,18 @@ impl TextData { } /// Apply `f` to the list settings of the paragraphs chars `start..end` touch (plain - /// paragraphs stay at level 0 with no start, levels stop at [`MAX_LIST_INDENT`]); one style - /// for all with no nesting or start folds back into `list`. + /// paragraphs stay at level 0, only numbered ones keep a start, levels stop at + /// [`MAX_LIST_INDENT`]); one style for all with no nesting or start folds back into `list`. pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) { let (a, b) = self.para_span(start, end); self.fill_paragraphs(); for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) { f(pl); if pl.list == ListStyle::None { - *pl = ParaList::default(); + pl.indent = 0; + } + if pl.list != ListStyle::Numbered { + pl.start_offset = 0; } pl.indent = pl.indent.min(MAX_LIST_INDENT); } @@ -793,21 +796,24 @@ mod case_tests { #[test] fn list_starts_load_from_old_files_and_follow_edits() { - use ListStyle::{None as P, Numbered as N}; + use ListStyle::{Bulleted as B, None as P, Numbered as N}; // Files written before start numbers load with none, and write the same bytes. let old = r#"{"list":"numbered","indent":1}"#; let pl: ParaList = serde_json::from_str(old).unwrap(); - assert_eq!(pl, ParaList { list: N, indent: 1, start: 0 }); + assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 }); assert_eq!(serde_json::to_string(&pl).unwrap(), old); // One numbered paragraph with a start keeps it (it doesn't fold into `list`). let mut t = TextData { characters: "Next".into(), ..Default::default() }; - t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start: 1 }); - assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start: 1 }); + t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 }); + assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 }); // Enter copies it to the new item; different starts don't make the list mixed. t.replace_range(4, 4, "\nMore"); - assert_eq!(t.para_list(1).start, 1); - t.set_para_lists(5, 5, |p| p.start = 0); + assert_eq!(t.para_list(1).start_offset, 1); + t.set_para_lists(5, 5, |p| p.start_offset = 0); assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N)); + // Bullets have no number, so no start. + t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 }); + assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 }); // A plain paragraph has none. t.set_para_lists(0, 0, |p| p.list = P); assert_eq!(t.para_list(0), ParaList::default()); diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 1f461c60..88bc015a 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -191,7 +191,7 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { o.insert("textDecorationSkipInk".into(), json!(l.skip_ink)); o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex())))); } - // Lists: one style for the layer, or each paragraph's type and nesting level. + // Lists: one style for the layer, or each paragraph's type, nesting level and start. if t.paragraphs.is_empty() { if t.list != figcraft_doc::ListStyle::None { o.insert("listStyle".into(), json!(t.list)); @@ -199,6 +199,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { } else { o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect()); o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect()); + if t.paragraphs.iter().any(|p| p.start_offset > 0) { + o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect()); + } } if t.list_spacing != 0.0 { o.insert("listSpacing".into(), json!(t.list_spacing)); diff --git a/crates/engine/src/text_tail_tests.rs b/crates/engine/src/text_tail_tests.rs index 687c67b8..81d05ed2 100644 --- a/crates/engine/src/text_tail_tests.rs +++ b/crates/engine/src/text_tail_tests.rs @@ -64,12 +64,15 @@ fn a_list_with_a_start_number_toggles_off() { let mut s = Session::new(); let t = text(&mut s, "two\nthree\nfour"); if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind { - td.paragraphs = - vec![ParaList { list: N, indent: 0, start: 1 }, ParaList { list: N, indent: 0, start: 0 }, ParaList { list: N, indent: 0, start: 0 }]; + let n = |start_offset| ParaList { list: N, indent: 0, start_offset }; + td.paragraphs = vec![n(1), n(0), n(0)]; } + let j = crate::inspect::node_json(s.doc().unwrap(), t, 0); + assert_eq!(j["listStartOffsets"], json!([1, 0, 0]), "inspect reports the start"); s.execute("text.numberList", &json!({"ids": [t]})).unwrap(); assert_eq!(lists(&s, t), [(P, 0); 3]); - assert_eq!(data(&s, t).para_list(0).start, 0, "a plain paragraph has no start"); + assert_eq!(data(&s, t).para_list(0).start_offset, 0, "a plain paragraph has no start"); + assert!(crate::inspect::node_json(s.doc().unwrap(), t, 0).get("listStartOffsets").is_none()); } #[test] @@ -132,7 +135,7 @@ fn list_keys_at_list_items() { for (chars, before, caret, k, shift, after, want, why) in cases { let mut s = Session::new(); let t = text(&mut s, chars); - let ps: Vec = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start: 0 }).collect(); + let ps: Vec = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start_offset: 0 }).collect(); if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind { td.paragraphs = ps; } @@ -156,7 +159,7 @@ fn paragraph_lists_follow_edits() { // A paragraph's list stays with it as text is typed, pasted and deleted: new paragraphs copy // the one they split from; joined paragraphs keep the first one's. let mut t = TextData::new("ab\ncd\nef"); - t.paragraphs = vec![ParaList { list: B, indent: 1, start: 0 }, ParaList::default(), ParaList { list: N, indent: 0, start: 0 }]; + t.paragraphs = vec![ParaList { list: B, indent: 1, start_offset: 0 }, ParaList::default(), ParaList { list: N, indent: 0, start_offset: 0 }]; let get = |t: &TextData| (0..t.paragraph_count()).map(|p| (t.para_list(p).list, t.para_list(p).indent)).collect::>(); t.replace_range(1, 1, "x\ny"); assert_eq!(get(&t), [(B, 1), (B, 1), (P, 0), (N, 0)], "a pasted newline: a new item like it"); @@ -171,7 +174,7 @@ fn paragraph_lists_follow_edits() { (seed >> 33) as usize % n.max(1) }; let mut t = TextData::new("a\nb\nc\nd"); - t.paragraphs = vec![ParaList { list: B, indent: 2, start: 0 }; 4]; + t.paragraphs = vec![ParaList { list: B, indent: 2, start_offset: 0 }; 4]; for _ in 0..500 { let n = t.char_count(); let (a, b) = (rnd(n + 2), rnd(n + 2)); diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index cb2b8bff..dc56ff79 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1565,6 +1565,20 @@ impl<'d> Exporter<'d> { data.extend([("characterStyleIDs", arr(ids)), ("styleOverrideTable", arr(entries))]); } let lists: Vec = (0..td.paragraph_count()).map(|p| td.para_list(p).list).collect(); + let first = |i: usize, l: &ListStyle| *l != ListStyle::None && (i == 0 || lists.get(i - 1) != Some(l)); + // Where each line's numbered list starts counting, less one: its first item's start, on + // every line of the list. + let mut run = 0; + let starts: Vec = lists + .iter() + .enumerate() + .map(|(i, l)| { + if first(i, l) { + run = td.para_list(i).start_offset; + } + if *l == ListStyle::Numbered { run } else { 0 } + }) + .collect(); // One entry per paragraph, each with every field a reader expects. let line = |(i, l): (usize, &ListStyle)| { let ty = match l { @@ -1573,16 +1587,15 @@ impl<'d> Exporter<'d> { ListStyle::Bulleted => "UNORDERED_LIST", }; let listed = *l != ListStyle::None; - let first = listed && (i == 0 || lists.get(i - 1) != Some(l)); - // Where a numbered list starts counting, less one. - let start = if first && *l == ListStyle::Numbered { td.para_list(i).start } else { 0 }; + let begins = first(i, l); + let start = starts.get(i).copied().unwrap_or(0); o(vec![ ("lineType", e(ty)), ("styleId", int(0)), ("indentationLevel", int(i64::from(listed))), ("sourceDirectionality", e("AUTO")), ("listStartOffset", int(i64::from(start))), - ("isFirstLineOfList", b(first)), + ("isFirstLineOfList", b(begins)), ]) }; data.push(("lines", arr(lists.iter().enumerate().map(line).collect()))); diff --git a/crates/format/src/fig/text_list_tests.rs b/crates/format/src/fig/text_list_tests.rs index 8d314ccd..eca7b0dc 100644 --- a/crates/format/src/fig/text_list_tests.rs +++ b/crates/format/src/fig/text_list_tests.rs @@ -42,7 +42,7 @@ fn text_node(id: u64, name: &str, chars: &str, lines: Vec>) -> Kv<'s fn starts(doc: &figcraft_doc::Document, name: &str) -> Vec<(ListStyle, u32)> { let NodeKind::Text(t) = &named(doc, name).kind else { panic!("{name} is no text") }; - (0..t.paragraph_count()).map(|p| t.para_list(p)).map(|p: ParaList| (p.list, p.start)).collect() + (0..t.paragraph_count()).map(|p| t.para_list(p)).map(|p: ParaList| (p.list, p.start_offset)).collect() } #[test] @@ -65,7 +65,7 @@ fn numbered_lists_keep_their_start() { assert_eq!(starts(&doc, "From 2"), [(N, 1)], "shows 2."); assert_eq!(starts(&doc, "First only"), [(N, 3), (N, 0), (N, 0)]); assert_eq!(starts(&doc, "Every line"), [(N, 3), (N, 3), (N, 3)]); - assert_eq!(starts(&doc, "Mixed"), [(P, 0), (N, 4), (B, 2)]); + assert_eq!(starts(&doc, "Mixed"), [(P, 0), (N, 4), (B, 0)], "bullets have no number, so no start"); assert_eq!(starts(&doc, "Junk"), [(N, 0)], "a negative offset counts from 1"); } @@ -73,13 +73,10 @@ fn numbered_lists_keep_their_start() { fn the_export_writes_where_a_list_starts() { let mut doc = figcraft_doc::Document::new("Lists"); let page = doc.first_page().unwrap(); - let mut td = figcraft_doc::TextData::new("two\nthree\nplain\nsix"); - td.paragraphs = vec![ - ParaList { list: ListStyle::Numbered, indent: 0, start: 1 }, - ParaList { list: ListStyle::Numbered, indent: 0, start: 1 }, - ParaList::default(), - ParaList { list: ListStyle::Numbered, indent: 0, start: 5 }, - ]; + use ListStyle::{Bulleted as B, None as P, Numbered as N}; + let mut td = figcraft_doc::TextData::new("two\nthree\nplain\nsix\ndot"); + let pl = |list, start_offset| ParaList { list, indent: 0, start_offset }; + td.paragraphs = vec![pl(N, 1), pl(N, 0), pl(P, 0), pl(N, 5), pl(B, 4)]; doc.create(page, NodeKind::Text(td), "List", 0.0, 0.0, 200.0, 80.0).unwrap(); let (bytes, _) = super::super::export(&doc, None).unwrap(); let a = Archive::open(&bytes).unwrap(); @@ -88,7 +85,9 @@ fn the_export_writes_where_a_list_starts() { let text = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("name").and_then(Kv::str) == Some("List")).unwrap(); let lines = text.get("textData").and_then(|d| d.get("lines")).map(Kv::list).unwrap(); let offsets: Vec = lines.iter().map(|l| l.get("listStartOffset").and_then(Kv::f64).unwrap()).collect(); - assert_eq!(offsets, [1.0, 0.0, 0.0, 5.0], "on the first line of each list only"); + // Every line of a numbered list carries its first item's start (a reader that takes it per + // line or per list counts the same); bullets and plain lines carry none. + assert_eq!(offsets, [1.0, 1.0, 0.0, 5.0, 0.0]); let (back, _) = import(&bytes, "x").unwrap(); - assert_eq!(starts(&back, "List"), [(ListStyle::Numbered, 1), (ListStyle::Numbered, 0), (ListStyle::None, 0), (ListStyle::Numbered, 5)]); + assert_eq!(starts(&back, "List"), [(N, 1), (N, 1), (P, 0), (N, 5), (B, 0)]); } diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index ea66e390..c8fd92ba 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -226,7 +226,7 @@ fn lists(v: &Value, td: &mut TextData) { _ => ListStyle::None, }, indent: indents.get(i).and_then(Value::as_f64).filter(|d| d.is_finite()).map_or(0, |d| d.clamp(0.0, 255.0) as u8), - start: starts.get(i).and_then(Value::as_f64).filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32), + start_offset: starts.get(i).and_then(Value::as_f64).filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32), }) .collect(); // Clamp the levels and fold one style for all back into the layer's list style. diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 5ba5dd48..e6c893b7 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -844,7 +844,7 @@ pub(crate) fn list_markers(lists: &[ParaList]) -> Vec { *c = (ListStyle::None, 0); } let Some(c) = count.get_mut(d) else { return String::new() }; - let first = usize::try_from(pl.start).unwrap_or(usize::MAX).saturating_add(1); + let first = usize::try_from(pl.start_offset).unwrap_or(usize::MAX).saturating_add(1); *c = if c.0 == pl.list { (pl.list, c.1.saturating_add(1)) } else { (pl.list, first) }; match (pl.list, d % 3) { (ListStyle::Numbered, 1) => format!("{}.", alpha(c.1)), diff --git a/crates/text/src/tail_tests.rs b/crates/text/src/tail_tests.rs index b1854dfd..2d0ad22b 100644 --- a/crates/text/src/tail_tests.rs +++ b/crates/text/src/tail_tests.rs @@ -43,11 +43,11 @@ fn starts(l: &TextLayout) -> Vec<(f64, Option)> { } fn pl(list: ListStyle, indent: u8) -> ParaList { - ParaList { list, indent, start: 0 } + ParaList { list, indent, start_offset: 0 } } -fn from(list: ListStyle, indent: u8, start: u32) -> ParaList { - ParaList { list, indent, start } +fn from(list: ListStyle, indent: u8, start_offset: u32) -> ParaList { + ParaList { list, indent, start_offset } } #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1b8cdcc4..6f0cce80 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -17,7 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs and list types (numbered lists keep the number they start at), vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs and list types (numbered lists keep the number they start at), vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: nested list levels and where a new list begins right after another numbered one (`isFirstLineOfList`), connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout, constraints, grids, components, sets, instances with overrides, component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | @@ -89,7 +89,7 @@ each. (font, with styles under their standard names such as "Semi Bold"; size; line height, a share of the font size as a raw multiplier; letter spacing, case, decoration, links), alignment, auto resize, truncation, paragraph spacing and indent, list types (with the number a numbered list starts - at; not yet checked in other software) and styled runs with their fills (indexed + at, on every line of the list; not yet checked in other software) and styled runs with their fills (indexed by UTF-16 unit); components (description, boolean, text and instance swap properties and the layers bound to them), component sets, instances (their main, property values, what the instance itself changes from its main, such as its size, name and paints, as an override addressed by the @@ -163,7 +163,7 @@ each. | Date | Change | Summary | |---|---|---| -| 2026-10-16 | minor | `.fig` numbered lists keep the number they start at (#686): a list Figma shows as starting at 2 imported as 1. The import reads each line's start offset, a text keeps it per paragraph (older files open unchanged), numbering counts on from it, and the export writes it on a list's first line. Nested list levels from `.fig` files are still read as the first level. Numbers unchanged | +| 2026-10-16 | minor | `.fig` numbered lists keep the number they start at (#686): a list Figma shows as starting at 2 imported as 1. The import reads each line's start offset, a numbered paragraph keeps it (older files open unchanged), numbering counts on from it, `get_node` reports it (`listStartOffsets`), and the export writes it on every line of the list. Nested list levels and a new list right after another numbered one are still not read from `.fig` files. Numbers unchanged | | 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. | | 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Opened `.fig` and file JSON documents link nested instances of variants (and any nested instance under a name of its own) to their main's layers; they stayed unlinked, so a second export lost what they changed. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | | 2026-10-12 | minor | `.fig` export made to open elsewhere (gap 1): the document and its pages say they are visible and opaque (left out, nothing was drawn), the schema in the file uses the format's own names, kinds, field ids and enum values, rectangles are rounded rectangles with four radii, frames and shapes carry their fill outline and plain strokes their stroke outline, text carries its layout settings, font styles use their standard names, line heights that are a share of the font size are raw multipliers, masks keep their type (read back too); the import reads per-side stroke weights whose zero sides are left out. Written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 4efee72a..331dc67d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -177,7 +177,8 @@ Or in a project's `.mcp.json`: - **Lists** are per paragraph: `listStyle` (`none`, `bulleted`, `numbered`) and `indentation` (the nesting level, 0–8) apply to the paragraphs chars `start`..`end` touch (all of them without a range); `get_node` reports one `listStyle` for the layer, or `lineTypes` and `lineIndentations` - per paragraph. `text.bulletList`, `text.numberList` and `text.listStyle` act on the selected + per paragraph, plus `listStartOffsets` when a numbered list starts past 1 (from a `.fig` file; + 1 shows "2."). `text.bulletList`, `text.numberList` and `text.listStyle` act on the selected paragraphs while text is edited. Layer props: `listSpacing` (px between list items, in place of `paragraphSpacing`), `hangingList` (markers outside the box) and `hangingPunctuation`. While editing, `key` Tab / Shift+Tab nest an item or move it out, Enter on an empty item and Backspace From 8c90ef962adb902a72c8127c9b0f63bd2df315bd Mon Sep 17 00:00:00 2001 From: Yarila Date: Fri, 16 Oct 2026 23:36:23 +0500 Subject: [PATCH 048/118] M7: open inputs for parameterized menu actions --- ROADMAP.md | 3 +- crates/ui-egui/locales/en.toml | 3 +- crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/locales/ru.toml | 1 + crates/ui-egui/src/dialogs.rs | 92 ++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 5 ++ crates/ui-egui/src/menu_input_tests.rs | 74 +++++++++++++++++++++ crates/ui-egui/src/menus.rs | 29 ++++++++ docs/gaps.md | 6 +- 9 files changed, 205 insertions(+), 9 deletions(-) create mode 100644 crates/ui-egui/src/menu_input_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index c3c12085..c79f0ea7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (menu and Actions parameter dialogs; bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -313,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Affected menu and Actions commands that need values now open the relevant input or panel instead of showing a parameter error; Preferences offers language in a dropdown. Headline numbers unchanged. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | | 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 3c127fc6..5d978897 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -829,6 +829,7 @@ name = "English" "No model credits were built into this copy." = "No model credits were built into this copy." "No number variables fit here." = "No number variables fit here." "No number variables yet. Create them in Local variables." = "No number variables yet. Create them in Local variables." +"No other pages" = "No other pages" "No paints" = "No paints" "No plugins on {server}." = "No plugins on {server}." "No plugins yet. Import a development plugin from its figcraft-plugin.json." = "No plugins yet. Import a development plugin from its figcraft-plugin.json." @@ -1603,4 +1604,4 @@ name = "English" "⌘ arrows" = "⌘ arrows" "⌘ click" = "⌘ click" "⌘ scroll" = "⌘ scroll" -"⌥ drag" = "⌥ drag" \ No newline at end of file +"⌥ drag" = "⌥ drag" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 901283f1..c0329964 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -830,6 +830,7 @@ status = "draft — needs native review" "No model credits were built into this copy." = "Esta copia no incluye créditos de modelos." "No number variables fit here." = "Ninguna variable numérica encaja aquí." "No number variables yet. Create them in Local variables." = "Aún no hay variables numéricas. Créalas en Variables locales." +"No other pages" = "No hay otras páginas" "No paints" = "Sin pinturas" "No plugins on {server}." = "No hay plugins en {server}." "No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Aún no hay plugins. Importa un plugin de desarrollo desde su figcraft-plugin.json." diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 3ab40757..4228f8bc 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -819,6 +819,7 @@ name = "Русский" "No model credits were built into this copy." = "В этой сборке нет сведений об использованных моделях." "No number variables fit here." = "Нет подходящих числовых переменных." "No number variables yet. Create them in Local variables." = "Числовых переменных пока нет. Создайте их в разделе «Локальные переменные»." +"No other pages" = "Других страниц нет" "No paints" = "Заливок нет" "No plugins on {server}." = "На {server} нет плагинов." "No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Плагинов пока нет. Импортируйте плагин для разработки из его figcraft-plugin.json." diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6e0ee3a6..31eb1adb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -71,6 +71,15 @@ pub enum AboutTab { Models, } +/// Values collected by a menu or Actions command before dispatching it to the engine. +#[derive(Clone, Debug, PartialEq)] +pub enum MenuInput { + RenameFile(String), + Link(String), + Offset(f64), + MoveToPage, +} + impl AboutTab { pub const ALL: [AboutTab; 3] = [AboutTab::About, AboutTab::Contributors, AboutTab::Models]; @@ -137,6 +146,7 @@ fn about_body(ui: &mut egui::Ui, t: &Tokens) { pub fn keys(app: &mut FigApp, ctx: &Context) { let u = &mut app.ui; let open = u.color_profile.is_some() + || u.menu_input.is_some() || u.find.is_some() || u.batch_rename.is_some() || u.confirm_close @@ -147,7 +157,7 @@ pub fn keys(app: &mut FigApp, ctx: &Context) { if !open || !ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { return; } - if u.color_profile.take().is_some() || u.find.take().is_some() || u.batch_rename.take().is_some() { + if u.menu_input.take().is_some() || u.color_profile.take().is_some() || u.find.take().is_some() || u.batch_rename.take().is_some() { return; } for flag in [&mut u.confirm_close, &mut u.export_preview, &mut u.shortcuts_panel, &mut u.preferences, &mut u.about] { @@ -265,12 +275,17 @@ pub fn show(app: &mut FigApp, ctx: &Context) { ui.label(tr("Language")); // Each language by its own name; System follows the OS (the browser on the web). let langs = i18n::languages().iter().filter(|l| crate::cjk::can_show(l.code)).map(|l| (l.code, l.name)); - for (code, name) in [("system", tr("System"))].into_iter().chain(langs) { - if ui.selectable_label(app.session.prefs.language.eq_ignore_ascii_case(code), name).clicked() { - app.run("pref.language", json!({"language": code})); - ui.ctx().request_repaint(); + let choices: Vec<_> = [("system", tr("System"))].into_iter().chain(langs).collect(); + let selected = + choices.iter().find(|(code, _)| app.session.prefs.language.eq_ignore_ascii_case(code)).map_or(tr("System"), |(_, name)| *name); + egui::ComboBox::from_id_salt("pref_language").selected_text(selected).width(220.0).show_ui(ui, |ui| { + for (code, name) in choices { + if ui.selectable_label(app.session.prefs.language.eq_ignore_ascii_case(code), name).clicked() { + app.run("pref.language", json!({"language": code})); + ui.ctx().request_repaint(); + } } - } + }); }); if let Some(lang) = i18n::current().filter(|l| l.draft) { let note = trf("{language}: draft translation, waiting for a native speaker's review.", &[("language", &lang.name)]); @@ -321,6 +336,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { app.ui.find = if open { Some((find, replace)) } else { None }; } batch_rename_dialog(app, ctx); + menu_input_dialog(app, ctx); crate::variables_panel::show(app, ctx); crate::dev_compare::show(app, ctx); crate::branch_ui::show(app, ctx); @@ -335,6 +351,70 @@ pub fn show(app: &mut FigApp, ctx: &Context) { crate::windows::confirm_close(app, ctx); } +/// Menus and Actions cannot pass a value themselves. Gather it here, then run the regular +/// command so the same validation and undo behaviour apply as for programmatic callers. +fn menu_input_dialog(app: &mut FigApp, ctx: &Context) { + let Some(mut input) = app.ui.menu_input.take() else { return }; + let title = match &input { + MenuInput::RenameFile(_) => i18n::command("file.rename", "Rename file"), + MenuInput::Link(_) => i18n::command("text.link", "Create link"), + MenuInput::Offset(_) => i18n::command("vector.offset", "Offset path"), + MenuInput::MoveToPage => i18n::command("object.moveToPage", "Move to page"), + }; + let mut open = true; + let mut action: Option<(&str, serde_json::Value)> = None; + window(&title, ctx).open(&mut open).show(ctx, |ui| { + ui.set_width(320.0); + match &mut input { + MenuInput::RenameFile(name) => { + let field = ui.add(egui::TextEdit::singleline(name).desired_width(300.0)); + if (ui.button(tr("Rename")).clicked() || field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) + && !name.trim().is_empty() + { + action = Some(("file.rename", json!({"name": name.trim()}))); + } + } + MenuInput::Link(url) => { + let field = ui.add(egui::TextEdit::singleline(url).hint_text("https://").desired_width(300.0)); + if (ui.button(tr("Done")).clicked() || field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) && !url.trim().is_empty() + { + action = Some(("text.link", json!({"url": url.trim()}))); + } + } + MenuInput::Offset(offset) => { + ui.add(egui::DragValue::new(offset).speed(1.0).range(-100_000.0..=100_000.0)); + if ui.button(tr("Offset")).clicked() { + action = Some(("vector.offset", json!({"offset": offset}))); + } + } + MenuInput::MoveToPage => { + let mut choices = 0; + if let Ok(ds) = app.session.ds() { + for &page in &ds.doc.pages { + if page == ds.page || ds.doc.is_divider(page) { + continue; + } + if let Some(node) = ds.doc.node(page) + && ui.button(&node.name).clicked() + { + action = Some(("object.moveToPage", json!({"page": page}))); + } + choices += 1; + } + } + if choices == 0 { + ui.label(tr("No other pages")); + } + } + } + }); + if let Some((id, params)) = action { + app.run(id, params); + } else if open { + app.ui.menu_input = Some(input); + } +} + /// The Rename layers dialog's fields. #[derive(Clone, Debug, Default, PartialEq)] pub struct BatchRenameUi { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20a8a77c..c4671391 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -132,6 +132,8 @@ mod live_tests; #[cfg(test)] mod live_text_tests; #[cfg(test)] +mod menu_input_tests; +#[cfg(test)] mod online_tests; #[cfg(test)] mod open_issues_tests; @@ -410,6 +412,8 @@ pub struct UiState { /// The tab the About window shows. pub about_tab: dialogs::AboutTab, pub preferences: bool, + /// A menu command that needs a value before the engine can run it. + pub menu_input: Option, pub shortcuts_panel: bool, /// The Keyboard shortcuts panel's tab and search text. pub shortcuts_tab: figcraft_engine::keymap::Group, @@ -537,6 +541,7 @@ impl Default for UiState { about: false, about_tab: dialogs::AboutTab::About, preferences: false, + menu_input: None, shortcuts_panel: false, shortcuts_tab: figcraft_engine::keymap::Group::Essential, shortcuts_query: String::new(), diff --git a/crates/ui-egui/src/menu_input_tests.rs b/crates/ui-egui/src/menu_input_tests.rs new file mode 100644 index 00000000..0de3757f --- /dev/null +++ b/crates/ui-egui/src/menu_input_tests.rs @@ -0,0 +1,74 @@ +//! Menu and Actions entries that need an input must collect it in the UI, not send `{}` to the +//! engine (which would show a parameter error or silently use a default). + +use figcraft_engine::Session; +use serde_json::json; + +use crate::dialogs::MenuInput; +use crate::layers_drag_tests::Ui; +use crate::menus; + +#[test] +fn preference_actions_open_preferences_without_changing_values() { + let mut ui = Ui::new(Session::new()); + ui.app.session.prefs.language = "en".into(); + ui.app.session.prefs.publish_server = "wss://example.test/".into(); + let before = ui.app.session.prefs.clone(); + for id in ["pref.language", "pref.userName", "pref.publishServer", "pref.nudge"] { + ui.app.ui.preferences = false; + menus::invoke(&mut ui.app, id); + assert!(ui.app.ui.preferences, "{id} did not open Preferences"); + assert!(ui.app.ui.last_error.is_none(), "{id} showed an error"); + assert_eq!(ui.app.session.prefs.language, before.language); + assert_eq!(ui.app.session.prefs.publish_server, before.publish_server); + assert_eq!(ui.app.session.prefs.nudge_small, before.nudge_small); + assert_eq!(ui.app.session.prefs.nudge_big, before.nudge_big); + } + // Programmatic callers still need the actual argument. + assert!(ui.app.session.execute("pref.language", &json!({})).is_err()); +} + +#[test] +fn menu_entries_collect_required_arguments_before_running() { + let mut ui = Ui::new(Session::new()); + let name = ui.app.session.doc().unwrap().name.clone(); + for (id, expected) in [ + ("file.rename", MenuInput::RenameFile(name)), + ("text.link", MenuInput::Link(String::new())), + ("vector.offset", MenuInput::Offset(0.0)), + ("object.moveToPage", MenuInput::MoveToPage), + ] { + ui.app.ui.menu_input = None; + menus::invoke(&mut ui.app, id); + assert_eq!(ui.app.ui.menu_input, Some(expected), "{id}"); + assert!(ui.app.ui.last_error.is_none(), "{id} showed an error"); + } + ui.frame(vec![]); + assert!(ui.app.ui.last_error.is_none()); +} + +#[test] +fn palette_entries_with_existing_pickers_show_them() { + let mut ui = Ui::new(Session::new()); + menus::invoke(&mut ui.app, "tool.set"); + assert_eq!(ui.app.ui.tool_flyout, Some("move")); + ui.app.ui.left_collapsed = true; + menus::invoke(&mut ui.app, "page.select"); + assert!(!ui.app.ui.left_collapsed); + assert!(ui.app.ui.pages_open); + menus::invoke(&mut ui.app, "file.openRecent"); + assert!(ui.app.ui.home); + assert_eq!(ui.app.ui.home_view.section, crate::home::Section::Recents); + assert!(ui.app.ui.last_error.is_none()); +} + +#[test] +fn batch_rename_opens_its_existing_dialog() { + let mut session = Session::new(); + let id = session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].clone(); + session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let mut ui = Ui::new(session); + menus::invoke(&mut ui.app, "object.batchRename"); + assert_eq!(ui.app.ui.batch_rename.as_ref().map(|d| d.ids.len()), Some(1)); + assert!(ui.app.ui.last_error.is_none()); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b9d3299a..ccdffa7f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -81,6 +81,10 @@ pub fn invoke(app: &mut FigApp, id: &str) { "file.placeImage" => app.import_dialog(true), "file.save" => app.save(), "file.saveAs" => app.save_as_dialog(), + "file.openRecent" => { + app.ui.home = true; + app.ui.home_view.section = crate::home::Section::Recents; + } "file.saveVersion" => crate::versions::start_save(app), "branch.create" => crate::versions::start_branch(app), "file.export" | "file.exportSvg" => app.ui.export_preview = true, @@ -128,6 +132,31 @@ pub fn invoke(app: &mut FigApp, id: &str) { app.services.clipboard_set_text(&format!("figcraft://node/{}", ids.first().map(|i| i.0).unwrap_or(0))); } "edit.findReplace" => app.ui.find = Some((String::new(), String::new())), + // The Actions palette invokes commands without parameters. These values are edited in + // Preferences; sending an empty object would either fail or silently reset a preference. + "pref.language" | "pref.userName" | "pref.publishServer" | "pref.nudge" => { + app.run("app.preferences", json!({})); + } + "file.rename" => { + if let Ok(doc) = app.session.doc() { + app.ui.menu_input = Some(crate::dialogs::MenuInput::RenameFile(doc.name.clone())); + } + } + "text.link" => app.ui.menu_input = Some(crate::dialogs::MenuInput::Link(String::new())), + "vector.offset" => app.ui.menu_input = Some(crate::dialogs::MenuInput::Offset(0.0)), + "object.moveToPage" => app.ui.menu_input = Some(crate::dialogs::MenuInput::MoveToPage), + "object.batchRename" => { + let ids = app.session.selection(); + if !ids.is_empty() { + crate::dialogs::open_batch_rename(app, &ids); + } + } + "tool.set" => app.ui.tool_flyout = Some("move"), + "page.select" => { + app.ui.left_tab = crate::LeftTab::File; + app.ui.left_collapsed = false; + app.ui.pages_open = true; + } // From a menu the link is pasted into the Join dialog (`collab.join {link}` joins directly). "collab.join" => crate::live::open_join(app, ""), // So is the server address for publishing (it carries the server key). diff --git a/docs/gaps.md b/docs/gaps.md index e8b111a8..c8ed80b5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (menu and Actions parameter dialogs; bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -138,6 +138,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe cursor for every tool and modifier, panel widths, dark pages (`field_tests`, `cursor_tests`, `cursors_tests`, `outline_options_tests`, `hide_publishing_tests`, `panel_width_tests`, `theme_pages_tests`). +- **Landed (2026-10-16):** affected menu and Actions entries that need values now open their existing + controls or collect the values before dispatch. The language choice in Preferences uses a + dropdown that fits in the dialog (`menu_input_tests.rs`). - **Evidence:** ROADMAP item 7 ("Left: tool by tool, panel by panel against the incumbent"). - **Impact:** Figma users notice the feel within minutes; it decides whether they stay. - **Estimate:** 40–60 h. Detail in [`ui-parity.md`](ui-parity.md). @@ -400,6 +403,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Gap 4: affected menu and Actions entries with required values open an input or the relevant panel before running the command; the language selector fits inside Preferences. Estimates and ranking unchanged. | | 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | | 2026-10-13 | minor | Gap 1: the experimental `.fig` export writes instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes, and the import reads them back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Hours and the ranking unchanged | From dc97118c6f0c8e3be88087d6e9a910d069be042e Mon Sep 17 00:00:00 2001 From: Ahmed5Emad Date: Fri, 16 Oct 2026 22:58:36 +0300 Subject: [PATCH 049/118] Actions palette rework: Figma-shaped panel over the toolbar Tabs (All, Assets, Plugins & widgets), Recents kept in the preferences (newest first, capped at 5), commands grouped by top-level menu, this file's components (placed at the view centre) and styles (applied to the selection), installed plugins and widgets with letter tiles. Rows carry right-side shortcut/kind hints; Enter runs the first visible row. The panel floats 8 px above the toolbar pill with its exact width and centre (toolbar::main_width, canvas-centred), at a fixed size so tab switches never resize it. Tabs keep their frames when idle so the active pill never moves its neighbours. Engine: Prefs.recent_actions (persisted, skipped when empty). UI: invoke/menu/plugin picks record recents; gone entries drop out. Locales: new keys in en + es, retired the old search hint everywhere. Docs: ui-parity row describes the new surface (palette_tests). --- .gitignore | 2 + crates/engine/src/lib.rs | 9 + crates/ui-egui/locales/cs.toml | 1 - crates/ui-egui/locales/de.toml | 1 - crates/ui-egui/locales/en.toml | 5 +- crates/ui-egui/locales/es.toml | 5 +- crates/ui-egui/locales/fr.toml | 1 - crates/ui-egui/locales/id.toml | 1 - crates/ui-egui/locales/ja.toml | 1 - crates/ui-egui/locales/ko.toml | 1 - crates/ui-egui/locales/pt-BR.toml | 1 - crates/ui-egui/locales/ru.toml | 1 - crates/ui-egui/locales/sv.toml | 1 - crates/ui-egui/locales/tr.toml | 1 - crates/ui-egui/locales/zh-Hans.toml | 1 - crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/lib.rs | 10 +- crates/ui-egui/src/menus.rs | 405 +++++++++++++++++++++++++--- crates/ui-egui/src/palette_tests.rs | 60 +++++ crates/ui-egui/src/plugins_ui.rs | 2 + crates/ui-egui/src/toolbar.rs | 15 +- docs/ui-parity.md | 2 +- 22 files changed, 467 insertions(+), 60 deletions(-) create mode 100644 crates/ui-egui/src/palette_tests.rs diff --git a/.gitignore b/.gitignore index 928d6b49..3709c74a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ plans/ /log/ .DS_Store *.profraw + +plugins/target/ diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..09b34120 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -652,8 +652,16 @@ pub struct Prefs { pub home_sort: String, /// Which way: `"desc"` (newest first, Z to A) or `"asc"`. pub home_order: String, + /// Recently run actions (the Actions palette's Recents), newest first, at most + /// [`MAX_RECENT_ACTIONS`]. Entries are `command:`, `plugin:/` or + /// `widget:`. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub recent_actions: Vec, } +/// Recently run actions kept for the Actions palette (newest first). +pub const MAX_RECENT_ACTIONS: usize = 5; + impl Default for Prefs { fn default() -> Self { Self { @@ -681,6 +689,7 @@ impl Default for Prefs { home_views: BTreeMap::new(), home_sort: "modified".into(), home_order: "desc".into(), + recent_actions: Vec::new(), } } } diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 268ff527..e86da423 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -1133,7 +1133,6 @@ status = "draft — needs native review" "Scroll with parent" = "Posouvat s nadřazeným prvkem" "Search plugins" = "Hledat pluginy" "Search" = "Hledat" -"Search actions, layers and tools" = "Hledat akce, vrstvy a nástroje" "Search assets" = "Hledat zdroje" "Search comments" = "Hledat komentáře" "Search fonts" = "Hledat písma" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index cd1e3482..862527e4 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1096,7 +1096,6 @@ status = "draft — needs native review" "Scroll to layer" = "Zur Ebene scrollen" "Scroll with parent" = "Mit übergeordnetem Element scrollen" "Search" = "Suchen" -"Search actions, layers and tools" = "Aktionen, Ebenen und Werkzeuge suchen" "Search assets" = "Assets suchen" "Search comments" = "Kommentare suchen" "Search fonts" = "Schriften suchen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 3c127fc6..703f018c 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -965,8 +965,10 @@ name = "English" "Place a shape or frame (its tool chosen)" = "Place a shape or frame (its tool chosen)" "Place automatically" = "Place automatically" "Place image" = "Place image" +"Plugin" = "Plugin" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." "Plugins" = "Plugins" +"Plugins & widgets" = "Plugins & widgets" "Plus darker" = "Plus darker" "Plus lighter" = "Plus lighter" "Point" = "Point" @@ -1148,7 +1150,7 @@ name = "English" "Scroll with parent" = "Scroll with parent" "Search plugins" = "Search plugins" "Search" = "Search" -"Search actions, layers and tools" = "Search actions, layers and tools" +"Search actions, assets and plugins" = "Search actions, assets and plugins" "Search assets" = "Search assets" "Search comments" = "Search comments" "Search fonts" = "Search fonts" @@ -1468,6 +1470,7 @@ name = "English" "White" = "White" "Who runs it, its terms and how long it keeps files" = "Who runs it, its terms and how long it keeps files" "Why it changed" = "Why it changed" +"Widget" = "Widget" "Widgets" = "Widgets" "Width" = "Width" "Width point" = "Width point" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 901283f1..554d9983 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -966,8 +966,10 @@ status = "draft — needs native review" "Place a shape or frame (its tool chosen)" = "Colocar forma o marco (herramienta elegida)" "Place automatically" = "Colocar automáticamente" "Place image" = "Colocar imagen" +"Plugin" = "Plugin" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Los plugins se ejecutan en el entorno aislado de FigCraft, sin archivos, red ni reloj. Instala solo plugins de confianza." "Plugins" = "Plugins" +"Plugins & widgets" = "Plugins & widgets" "Plus darker" = "Más oscuro" "Plus lighter" = "Más claro" "Point" = "Punto" @@ -1149,8 +1151,8 @@ status = "draft — needs native review" "Scroll with parent" = "Desplazar con el padre" "Search plugins" = "Buscar plugins" "Search" = "Buscar" -"Search actions, layers and tools" = "Buscar acciones, capas y herramientas" "Search assets" = "Buscar recursos" +"Search actions, assets and plugins" = "Buscar acciones, recursos y plugins" "Search comments" = "Buscar comentarios" "Search fonts" = "Buscar fuentes" "Search layers" = "Buscar capas" @@ -1469,6 +1471,7 @@ status = "draft — needs native review" "White" = "Blanco" "Who runs it, its terms and how long it keeps files" = "Quién lo gestiona, sus condiciones y cuánto tiempo conserva los archivos" "Why it changed" = "Por qué cambió" +"Widget" = "Widget" "Widgets" = "Widgets" "Width" = "Ancho" "Width point" = "Punto de ancho" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 7fafd3c9..2c1fbc45 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -1090,7 +1090,6 @@ status = "draft — needs native review" "Scroll to layer" = "Défiler jusqu’au calque" "Scroll with parent" = "Défiler avec le parent" "Search" = "Rechercher" -"Search actions, layers and tools" = "Rechercher des actions, des calques et des outils" "Search assets" = "Rechercher des ressources" "Search comments" = "Rechercher des commentaires" "Search fonts" = "Rechercher des polices" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 5de7634d..53825747 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -1137,7 +1137,6 @@ status = "draft — needs native review" "Scroll with parent" = "Gulir bersama induk" "Search plugins" = "Cari plugin" "Search" = "Cari" -"Search actions, layers and tools" = "Cari tindakan, layer, dan alat" "Search assets" = "Cari aset" "Search comments" = "Cari komentar" "Search fonts" = "Cari font" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 4d4d6d34..670f84fc 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -1140,7 +1140,6 @@ status = "draft — needs native review" "Scroll to layer" = "レイヤーまでスクロール" "Scroll with parent" = "親と一緒にスクロール" "Search" = "検索" -"Search actions, layers and tools" = "アクション、レイヤー、ツールを検索" "Search assets" = "アセットを検索" "Search comments" = "コメントを検索" "Search fonts" = "フォントを検索" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index ec732905..1f7bed0c 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1090,7 +1090,6 @@ status = "draft — needs native review" "Scroll to layer" = "레이어로 스크롤" "Scroll with parent" = "부모와 함께 스크롤" "Search" = "검색" -"Search actions, layers and tools" = "액션, 레이어, 도구 검색" "Search assets" = "에셋 검색" "Search comments" = "댓글 검색" "Search fonts" = "글꼴 검색" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index c2189de8..2fb61d12 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -1089,7 +1089,6 @@ status = "draft — needs native review" "Scroll to layer" = "Rolar até a camada" "Scroll with parent" = "Rolar com o pai" "Search" = "Pesquisar" -"Search actions, layers and tools" = "Pesquisar ações, camadas e ferramentas" "Search assets" = "Pesquisar recursos" "Search comments" = "Pesquisar comentários" "Search fonts" = "Pesquisar fontes" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 3ab40757..78a8afd6 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -1134,7 +1134,6 @@ name = "Русский" "Scroll with parent" = "Прокручивать с родителем" "Search plugins" = "Поиск плагинов" "Search" = "Поиск" -"Search actions, layers and tools" = "Поиск действий, слоёв и инструментов" "Search assets" = "Поиск ресурсов" "Search comments" = "Поиск комментариев" "Search fonts" = "Поиск шрифтов" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index f7b2999d..a54c02e7 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -1135,7 +1135,6 @@ name = "Svenska" "Scroll with parent" = "Rulla med överordnat element" "Search plugins" = "Sök plugin-program" "Search" = "Sök" -"Search actions, layers and tools" = "Sök åtgärder, lager och verktyg" "Search assets" = "Sök tillgångar" "Search comments" = "Sök i kommentarer" "Search fonts" = "Sök typsnitt" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index a2be6b99..43603287 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -1137,7 +1137,6 @@ name = "Türkçe" "Scroll with parent" = "Üst öğeyle birlikte kaydır" "Search plugins" = "Eklentilerde ara" "Search" = "Ara" -"Search actions, layers and tools" = "Eylem, katman ve araç ara" "Search assets" = "Varlıklarda ara" "Search comments" = "Yorumlarda ara" "Search fonts" = "Yazı tiplerinde ara" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index af4cf653..a42360e4 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -1091,7 +1091,6 @@ status = "draft — needs native review" "Scroll to layer" = "滚动到图层" "Scroll with parent" = "随父级滚动" "Search" = "搜索" -"Search actions, layers and tools" = "搜索操作、图层和工具" "Search assets" = "搜索资源" "Search comments" = "搜索评论" "Search fonts" = "搜索字体" diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7a196cf2..9dd8e2f4 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -415,6 +415,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul } if let Some(h) = p.get("palette").and_then(Value::as_bool) { app.ui.palette = if h { Some(String::new()) } else { None }; + app.ui.palette_tab = crate::menus::PaletteTab::All; } // true / false, or a top-level menu to open it at ("Plugins"). match p.get("mainMenu") { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20a8a77c..46830316 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -136,6 +136,8 @@ mod online_tests; #[cfg(test)] mod open_issues_tests; #[cfg(test)] +mod palette_tests; +#[cfg(test)] mod panel_remove_tests; #[cfg(test)] mod panel_reorder_tests; @@ -404,6 +406,8 @@ pub struct UiState { pub rename_page: Option<(NodeId, String)>, pub main_menu: bool, pub palette: Option, + /// The Actions palette's tab. + pub palette_tab: menus::PaletteTab, /// The Variables editor, when open. pub variables: Option, pub about: bool, @@ -533,6 +537,7 @@ impl Default for UiState { rename_page: None, main_menu: false, palette: None, + palette_tab: menus::PaletteTab::All, variables: None, about: false, about_tab: dialogs::AboutTab::About, @@ -788,7 +793,10 @@ impl FigApp { } "dialog.preferences" => self.ui.preferences = true, "panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel, - "palette.actions" => self.ui.palette = Some(String::new()), + "palette.actions" => { + self.ui.palette = Some(String::new()); + self.ui.palette_tab = menus::PaletteTab::All; + } "panel.design" => self.ui.right_tab = RightTab::Design, "panel.prototype" => self.ui.right_tab = RightTab::Prototype, "panel.designPrototype" => { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b9d3299a..0e079313 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -7,9 +7,9 @@ use serde_json::json; use crate::FigApp; use crate::a11y; -use crate::i18n::{self, tr, tr_noop, trf}; use crate::icons::{self, Icon}; -use crate::theme::{ACCENT, Tokens, regular}; +use crate::i18n::{self, tr, tr_noop, trf}; +use crate::theme::{Tokens, regular}; use crate::toolbar::pretty_shortcut; /// Commands the UI layer implements itself (dialogs and platform actions). @@ -73,7 +73,28 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec, u32, u32)> { Some((out.into_inner(), w, h)) } +/// The Actions palette's tab. +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum PaletteTab { + #[default] + All, + Assets, + Plugins, +} + +/// Remember a ran action for the palette's Recents (newest first, capped). +pub fn record_recent(app: &mut FigApp, key: &str) { + let r = &mut app.session.prefs.recent_actions; + if r.first().is_some_and(|f| f == key) { + return; + } + r.retain(|k| k != key); + r.insert(0, key.to_string()); + r.truncate(figcraft_engine::MAX_RECENT_ACTIONS); +} + pub fn invoke(app: &mut FigApp, id: &str) { + record_recent(app, &format!("command:{id}")); match id { "file.open" => app.open_dialog(), "plugin.import" => crate::plugins_ui::import_dialog(app), @@ -612,14 +633,25 @@ fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2, t: &Tokens) } } -/// The Actions palette: fuzzy search over every command. +/// The Actions palette's content height (the frame adds its own 8 px margins). +const PALETTE_H: f32 = 450.0; + +/// The Actions palette: search over commands, assets and plugins, in Figma's shape (tabs, +/// recents, grouped sections). Rows run on click; Enter runs the first visible row. fn palette(app: &mut FigApp, ctx: &egui::Context) { let Some(mut q) = app.ui.palette.clone() else { return }; let t = Tokens::get(ctx); - let screen = ctx.content_rect(); + // Same centre and width as the toolbar pill (it centres on the canvas, not the window), + // floating 8 px above it. Fixed size: switching tabs never resizes the panel. + let bar_w = crate::toolbar::main_width(); + let canvas = app.canvas.rect; + let area = Rect::from_min_size( + pos2(canvas.center().x - bar_w / 2.0, canvas.max.y - 68.0 - (PALETTE_H + 16.0)), + vec2(bar_w, PALETTE_H + 16.0), + ); let mut run: Option = None; let mut close = false; - egui::Area::new(Id::new("palette")).fixed_pos(pos2(screen.center().x - 260.0, screen.min.y + 120.0)).order(egui::Order::Foreground).show( + egui::Area::new(Id::new("palette")).fixed_pos(area.min).order(egui::Order::Foreground).show( ctx, |ui| { egui::Frame::NONE @@ -629,64 +661,353 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) { .shadow(egui::Shadow { offset: [0, 8], blur: 32, spread: 0, color: t.shadow }) .inner_margin(egui::Margin::same(8)) .show(ui, |ui| { - ui.set_width(520.0); + // Same outer width as the toolbar pill, and a fixed height: switching tabs + // never resizes the panel. + let inner = crate::toolbar::main_width() - 16.0; + ui.set_width(inner); + ui.set_height(PALETTE_H); let r = ui.add( egui::TextEdit::singleline(&mut q) - .hint_text(tr("Search actions, layers and tools")) - .desired_width(500.0) + .hint_text(tr("Search actions, assets and plugins")) + .desired_width(inner - 16.0) .font(regular(14.0)) .frame(egui::Frame::NONE.inner_margin(egui::Margin::same(8))), ); r.request_focus(); crate::widgets::select_all_on_focus(ui, &r, &q); ui.add_space(4.0); - let ql = q.to_lowercase(); - // Labels match in the UI language and in English. - let matches = - |label: &str, english: &str, id: &str| ql.is_empty() || [label, english, id].iter().any(|s| s.to_lowercase().contains(&ql)); - let mut hits: Vec<(&str, std::borrow::Cow, &str)> = Vec::new(); - let menu_items = MENU.iter().filter(|m| UI_COMMANDS.contains(&m.id)).map(|m| (m.id, m.label)); - for (id, english) in figcraft_engine::all_commands().iter().map(|c| (c.id, c.label)).chain(menu_items) { - let label = i18n::command(id, english); - if matches(&label, english, id) { - hits.push((id, label, crate::shortcuts::shortcut(id).unwrap_or(""))); - } - } - hits.sort_by_key(|h| (!h.1.to_lowercase().starts_with(&ql), h.1.len())); - hits.dedup_by(|a, b| a.0 == b.0); - let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - for (i, (id, label, sc)) in hits.iter().take(60).enumerate() { - let w = ui.available_width(); - let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click()); - if resp.hovered() || i == 0 { - ui.painter().rect_filled(r, CornerRadius::same(5), if resp.hovered() { ACCENT } else { t.hover }); - } - let col = if resp.hovered() { Color32::WHITE } else { t.text }; - ui.painter().text(pos2(r.min.x + 10.0, r.center().y), Align2::LEFT_CENTER, label.as_ref(), regular(12.0), col); - ui.painter().text( - pos2(r.max.x - 10.0, r.center().y), - Align2::RIGHT_CENTER, - pretty_shortcut(sc), - regular(11.0), - if resp.hovered() { Color32::WHITE } else { t.text_secondary }, + // Tabs: All, Assets, Plugins & widgets. + let tabs = [(PaletteTab::All, tr("All")), (PaletteTab::Assets, tr("Assets")), (PaletteTab::Plugins, tr("Plugins & widgets"))]; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = vec2(4.0, 0.0); + for (tab, label) in tabs { + let active = app.ui.palette_tab == tab; + // The frame stays on for every tab (transparent when idle) so the + // active pill never moves its neighbours. + let resp = ui.add( + egui::Button::new(egui::RichText::new(label).size(12.0).color(if active { t.text } else { t.text_secondary })) + .frame(true) + .fill(if active { t.hover } else { Color32::TRANSPARENT }) + .corner_radius(CornerRadius::same(6)), ); - if resp.clicked() || (enter && i == 0) { - run = Some(id.to_string()); + if resp.clicked() { + app.ui.palette_tab = tab; } } }); + ui.add_space(4.0); + let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); + let mut first: Option = None; + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + let tab = app.ui.palette_tab; + if tab == PaletteTab::All { + let recents: Vec = app + .session + .prefs + .recent_actions + .clone() + .iter() + .filter_map(|key| recent_row(app, key, &q)) + .collect(); + if !recents.is_empty() { + section(ui, &t, tr("Recents"), |ui| { + for row in &recents { + first.get_or_insert_with(|| row.key.clone()); + if draw_row(ui, &t, row) { + run = Some(row.key.clone()); + } + } + }); + } + } + if tab != PaletteTab::Plugins { + for (group, rows) in command_sections(&q) { + if tab == PaletteTab::Assets { + continue; + } + section(ui, &t, &group, |ui| { + for row in rows { + first.get_or_insert_with(|| row.key.clone()); + if draw_row(ui, &t, &row) { + run = Some(row.key); + } + } + }); + } + } + if tab != PaletteTab::Assets { + let plugins = plugin_rows(app, &q); + let widgets = widget_rows(app, &q); + if !plugins.is_empty() { + section(ui, &t, tr("Plugins"), |ui| { + for row in plugins { + first.get_or_insert_with(|| row.key.clone()); + if draw_row(ui, &t, &row) { + run = Some(row.key); + } + } + }); + } + if !widgets.is_empty() { + section(ui, &t, tr("Widgets"), |ui| { + for row in widgets { + first.get_or_insert_with(|| row.key.clone()); + if draw_row(ui, &t, &row) { + run = Some(row.key); + } + } + }); + } + } + if tab == PaletteTab::Assets { + for (group, rows) in asset_sections(app, &q) { + section(ui, &t, &group, |ui| { + for row in rows { + first.get_or_insert_with(|| row.key.clone()); + if draw_row(ui, &t, &row) { + run = Some(row.key); + } + } + }); + } + } + }); + if enter { + if let Some(key) = first { + run = Some(key); + } + } }); }, ); if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { close = true; } - if let Some(id) = run { + if let Some(key) = run { app.ui.palette = None; - invoke(app, &id); + run_action(app, &key); return; } app.ui.palette = if close { None } else { Some(q) }; let _ = StrokeKind::Inside; } + +/// Run a palette row: `command:`, `plugin:/`, `widget:`, +/// `component:` or `style:`. +fn run_action(app: &mut FigApp, key: &str) { + if let Some(id) = key.strip_prefix("command:") { + invoke(app, id); + } else if let Some(rest) = key.strip_prefix("plugin:") { + if let Some((plugin, command)) = rest.split_once('/') { + record_recent(app, key); + crate::plugins_ui::start(app, plugin, command); + } + } else if let Some(plugin) = key.strip_prefix("widget:") { + record_recent(app, key); + app.run("widget.insert", json!({ "plugin": plugin })); + } else if let Some(node) = key.strip_prefix("component:") { + if let Ok(n) = node.parse::() { + let (cx, cy) = app.session.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0)); + app.run("instance.place", json!({ "component": n, "x": cx, "y": cy })); + } + } else if let Some(style) = key.strip_prefix("style:") { + if app.session.selection().is_empty() { + app.toast(tr("Select layers to apply the style to")); + } else { + app.run("style.apply", json!({ "style": style })); + } + } +} + +/// One pickable palette row. +pub(crate) struct ActionRow { + pub(crate) key: String, + pub(crate) label: String, + hint: String, + /// A letter tile instead of an icon (plugins carry no artwork). + tile: Option, +} + +/// A section header with its rows. +fn section(ui: &mut Ui, t: &Tokens, name: &str, rows: impl FnOnce(&mut Ui)) { + ui.add_space(6.0); + ui.painter().text(pos2(ui.min_rect().min.x + 10.0, ui.cursor().min.y + 7.0), Align2::LEFT_TOP, name, regular(11.0), t.text_secondary); + ui.add_space(18.0); + rows(ui); +} + +/// Draw one row (a letter tile for plugins, else just the label) with its right-side hint. +/// True when clicked. +fn draw_row(ui: &mut Ui, t: &Tokens, row: &ActionRow) -> bool { + let w = ui.available_width(); + let (r, resp) = ui.allocate_exact_size(vec2(w, 34.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(6), t.hover); + } + let mut label_x = r.min.x + 10.0; + if let Some(letter) = row.tile { + let tile = Rect::from_center_size(pos2(r.min.x + 20.0, r.center().y), vec2(18.0, 18.0)); + ui.painter().rect_filled(tile, CornerRadius::same(5), t.hover); + ui.painter().text(tile.center(), Align2::CENTER_CENTER, letter.to_string(), regular(11.0), t.text); + label_x = r.min.x + 38.0; + } + ui.painter().text(pos2(label_x, r.center().y), Align2::LEFT_CENTER, &row.label, regular(13.0), t.text); + ui.painter().text(pos2(r.max.x - 10.0, r.center().y), Align2::RIGHT_CENTER, &row.hint, regular(11.0), t.text_secondary); + resp.clicked() +} + +/// Whether `label` (or `extra`) matches the query, in any case. +fn matches(q: &str, label: &str, extra: &str) -> bool { + q.is_empty() || label.to_lowercase().contains(q) || extra.to_lowercase().contains(q) +} + +/// Commands grouped by top-level menu, in catalog order, filtered by the query. +pub(crate) fn command_sections(q: &str) -> Vec<(String, Vec)> { + let ql = q.to_lowercase(); + let mut groups: Vec<(String, Vec)> = Vec::new(); + let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); + for m in figcraft_engine::catalog::MENU.iter() { + if m.label == figcraft_engine::catalog::SEP || m.menu.starts_with("Plugins") || !available(m.id) || !seen.insert(m.id) { + continue; + } + let english = m.label; + let label = i18n::menu_item(m.id, english).into_owned(); + if !matches(&ql, &label, &format!("{english} {}", m.id)) { + continue; + } + let group = m.menu.split('/').next().unwrap_or(&m.menu); + let group = tr(group).to_string(); + let row = ActionRow { + key: format!("command:{}", m.id), + label, + hint: crate::shortcuts::shortcut(m.id).map(pretty_shortcut).unwrap_or_default(), + tile: None, + }; + match groups.iter_mut().find(|(g, _)| *g == group) { + Some((_, rows)) => rows.push(row), + None => groups.push((group, vec![row])), + } + } + groups +} + +/// Installed plugins' commands, filtered by the query. +fn plugin_rows(app: &FigApp, q: &str) -> Vec { + let ql = q.to_lowercase(); + let mut rows = Vec::new(); + for p in &app.session.prefs.plugins { + let m = &p.manifest; + for c in &m.commands { + let label = if m.commands.len() == 1 { m.name.clone() } else { format!("{} — {}", m.name, c.label) }; + if !matches(&ql, &label, &m.id) { + continue; + } + rows.push(ActionRow { + key: format!("plugin:{}/{}", m.id, c.id), + label, + hint: if c.params.is_empty() { tr("Plugin").to_string() } else { c.label.clone() }, + tile: m.name.chars().next(), + }); + } + } + rows +} + +/// Installed widgets, filtered by the query. +fn widget_rows(app: &FigApp, q: &str) -> Vec { + let ql = q.to_lowercase(); + app.session + .prefs + .plugins + .iter() + .filter(|p| p.manifest.widget) + .filter(|p| matches(&ql, &p.manifest.name, &p.manifest.id)) + .map(|p| ActionRow { + key: format!("widget:{}", p.manifest.id), + label: p.manifest.name.clone(), + hint: tr("Widget").to_string(), + tile: p.manifest.name.chars().next(), + }) + .collect() +} + +/// This file's components and styles, filtered by the query. +fn asset_sections(app: &FigApp, q: &str) -> Vec<(String, Vec)> { + let ql = q.to_lowercase(); + let Ok(doc) = app.session.doc() else { return Vec::new() }; + let components: Vec = figcraft_engine::library::local_components(doc) + .iter() + .filter_map(|id| doc.node(*id)) + .filter(|n| matches(&ql, &n.name, "")) + .map(|n| ActionRow { + key: format!("component:{}", n.id.0), + label: n.name.clone(), + hint: tr("Component").to_string(), + tile: None, + }) + .collect(); + let styles: Vec = figcraft_engine::library::local_styles(doc) + .filter(|s| matches(&ql, &s.name, "")) + .map(|s| ActionRow { + key: format!("style:{}", s.id), + label: s.name.clone(), + hint: tr("Style").to_string(), + tile: None, + }) + .collect(); + let mut out = Vec::new(); + if !components.is_empty() { + out.push((tr("Components").to_string(), components)); + } + if !styles.is_empty() { + out.push((tr("Styles").to_string(), styles)); + } + out +} + +/// A recent action back as a row (gone commands, plugins or assets drop out). +pub(crate) fn recent_row(app: &FigApp, key: &str, q: &str) -> Option { + let ql = q.to_lowercase(); + if let Some(id) = key.strip_prefix("command:") { + let spec = figcraft_engine::find_command(id); + let menu_label = figcraft_engine::catalog::MENU.iter().find(|m| m.id == id); + let english = menu_label.map(|m| m.label).or_else(|| spec.map(|s| s.label)).unwrap_or(id); + if !available(id) || !matches(&ql, english, id) { + return None; + } + return Some(ActionRow { + key: key.to_string(), + label: i18n::command(id, english).into_owned(), + hint: crate::shortcuts::shortcut(id).map(pretty_shortcut).unwrap_or_default(), + tile: None, + }); + } + if let Some(rest) = key.strip_prefix("plugin:") { + let (pid, cid) = rest.split_once('/')?; + let p = app.session.prefs.plugins.iter().find(|p| p.manifest.id == pid)?; + let c = p.manifest.commands.iter().find(|c| c.id == cid)?; + let label = if p.manifest.commands.len() == 1 { p.manifest.name.clone() } else { format!("{} — {}", p.manifest.name, c.label) }; + if !matches(&ql, &label, pid) { + return None; + } + return Some(ActionRow { + key: key.to_string(), + label, + hint: tr("Plugin").to_string(), + tile: p.manifest.name.chars().next(), + }); + } + if let Some(pid) = key.strip_prefix("widget:") { + let p = app.session.prefs.plugins.iter().find(|p| p.manifest.id == pid && p.manifest.widget)?; + if !matches(&ql, &p.manifest.name, pid) { + return None; + } + return Some(ActionRow { + key: key.to_string(), + label: p.manifest.name.clone(), + hint: tr("Widget").to_string(), + tile: p.manifest.name.chars().next(), + }); + } + None +} diff --git a/crates/ui-egui/src/palette_tests.rs b/crates/ui-egui/src/palette_tests.rs new file mode 100644 index 00000000..e496cb77 --- /dev/null +++ b/crates/ui-egui/src/palette_tests.rs @@ -0,0 +1,60 @@ +//! The Actions palette, driven headless: invoking records recents (newest first, capped), +//! sections group commands by menu, the query filters, and gone entries drop out of Recents. + +use figcraft_engine::Session; + +use crate::layers_drag_tests::Ui; + +#[test] +fn invoke_records_recents_newest_first_and_capped() { + let mut u = Ui::new(Session::new()); + for id in [ + "view.zoomIn", + "view.zoomOut", + "edit.copyAsText", + "style.list", + "edit.findReplace", + "edit.copyAsCss", + "view.zoomIn", + ] { + crate::menus::invoke(&mut u.app, id); + } + let r = &u.app.session.prefs.recent_actions; + assert_eq!(r.len(), figcraft_engine::MAX_RECENT_ACTIONS, "recents are capped: {r:?}"); + assert_eq!(r[0], "command:view.zoomIn", "a re-run moves to the front: {r:?}"); + assert_eq!(r.iter().filter(|k| k.as_str() == "command:view.zoomIn").count(), 1, "no duplicates: {r:?}"); + assert!(!r.contains(&"command:view.zoomOut".to_string()), "the oldest drops out: {r:?}"); +} + +#[test] +fn sections_group_by_menu_in_catalog_order() { + let groups = crate::menus::command_sections(""); + assert!(!groups.is_empty(), "the catalog has commands"); + assert_eq!(groups[0].0, "File", "catalog order starts at File"); + for (group, rows) in &groups { + assert!(!rows.is_empty(), "{group} has no rows"); + for row in rows { + assert!(row.key.starts_with("command:"), "unexpected key {}", row.key); + } + } + let names: Vec<&str> = groups.iter().map(|(g, _)| g.as_str()).collect(); + assert!(names.contains(&"File") && names.contains(&"Edit"), "menus group: {names:?}"); + assert!(!names.iter().any(|g| g.starts_with("Plugins")), "plugins have their own tab"); +} + +#[test] +fn query_filters_to_matches() { + let groups = crate::menus::command_sections("save"); + let labels: Vec<&str> = groups.iter().flat_map(|(_, rows)| rows.iter().map(|r| r.label.as_str())).collect(); + assert!(!labels.is_empty(), "something matches 'save'"); + assert!(labels.iter().all(|l| l.to_lowercase().contains("save")), "only matches: {labels:?}"); + assert!(crate::menus::command_sections("zzz-no-such-action").iter().all(|(_, r)| r.is_empty())); +} + +#[test] +fn gone_entries_drop_out_of_recents() { + let u = Ui::new(Session::new()); + assert!(crate::menus::recent_row(&u.app, "command:nope.nope", "").is_none(), "gone commands drop out"); + assert!(crate::menus::recent_row(&u.app, "plugin:nope.nope/nope", "").is_none(), "gone plugins drop out"); + assert!(crate::menus::recent_row(&u.app, "widget:nope", "").is_none(), "gone widgets drop out"); +} diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 481b446c..99df5d6f 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -171,10 +171,12 @@ pub fn menu(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> bool { .flatten(); crate::menus::separator(ui); if let Some(plugin) = widget { + crate::menus::record_recent(app, &format!("widget:{plugin}")); app.run("widget.insert", json!({"plugin": plugin})); return true; } let Some((id, command)) = chosen else { return false }; + crate::menus::record_recent(app, &format!("plugin:{id}/{command}")); start(app, &id, &command); true } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index a921cc29..0aef7555 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -72,9 +72,18 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { builder_toolbar(app, ui); return; } - let cell = 32.0; - let gap = 4.0; - let width = GROUPS.len() as f32 * (cell + 4.0) + 4.0 * 10.0 + 2.0 * (cell + 4.0) + 16.0 + 4.0 * 34.0 + 24.0; + main_toolbar(app, ui, canvas, &t); +} + +/// The main toolbar pill's width: the Actions palette matches it so the two read as one bar. +pub(crate) fn main_width() -> f32 { + let (cell, gap) = (32.0, 4.0); + GROUPS.len() as f32 * (cell + gap) + gap * 10.0 + 2.0 * (cell + gap) + 16.0 + 4.0 * 34.0 + 24.0 +} + +fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { + let (cell, gap) = (32.0, 4.0); + let width = main_width(); let rect = pill(ui, &mut app.ui.nav, canvas, width, &t); let current = app.session.tool.tool; let mut x = rect.min.x + 8.0; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 62001715..4a7bf45b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -19,7 +19,7 @@ interaction feel not yet compared. | Light and dark themes | done | colours from `theme::Tokens`; a file or a page made while the dark theme shows gets a `#1E1E1E` page (`#F5F5F5` in the light theme), its own from then on: changing the theme repaints no page and opened files keep theirs; the file the app opens with takes the theme once it is known; the CLI, MCP and exports have no theme and keep `#F5F5F5`; text drawn with the Text tool is white on a dark page and black on a light one (so is text a command makes in the app without naming a fill; headless sessions keep black) (`theme_pages_tests`, `dark_page_text_tests`) | | Minimize UI (Cmd+\\) | done | | | Resizable panels | done | each panel's edge drags its width (left 200–480, right 240–480, 240 by default; a double-click on the edge or View ▸ Panels ▸ Reset panel widths puts the default back); the widths are each window's own, the main window's are kept in the preferences when a drag ends and come back at the next start, a new window opens with those of the window it comes from; Minimize UI hides the panels and brings them back as they were; the view keeps its centre and zoom while a panel is resized; in a window too small for them the panels give way so the canvas keeps 160 px (the left one first), down to the smallest window (800×500) (`panel_width_tests`). Not compared with Figma's widths; no snap points | -| Actions palette (Cmd+K, Cmd+/) | done | commands; no assets search parity | +| Actions palette (Cmd+K, Cmd+/) | done | tabs (All, Assets, Plugins & widgets), Recents kept in the preferences, commands grouped by menu, this file's components (placed at the view centre) and styles (applied to the selection), installed plugins and widgets with letter tiles; rows carry icons and shortcut hints (`palette_tests`) | | Context menus | done | canvas and layers; Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key isn't delivered by the windowing layer (egui 0.36 has no key for it) | | Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open files, recent files (cards with the picture each file carries on its page colour, the file's icon, name and "Edited ... ago" or "File not found", full path on hover; or a Name, Last modified and Created table with sortable headers; sorted by name, creation or last change either way; grid or list and the sort kept in the preferences, `ui.set {homeRecentsView, homeRecentsSort, homeRecentsOrder}`; a click selects, a double-click or Enter opens, arrow keys move the selection; Open, Show in folder and Remove from recents on right-click), a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder | | Main menu from the logo | done | generated from `catalog.rs`; popups sized to content | From b0f56ed1698b94d31ec6e6b9f2dcf0a1b21a40a5 Mon Sep 17 00:00:00 2001 From: sushi-killer <46325863+sushi-killer@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:19:32 +0300 Subject: [PATCH 050/118] fix(server): let message limits follow max_file_mb so files above 64 MiB publish and open --- apps/figcraft-server/src/config.rs | 26 ++++++++++++++---------- apps/figcraft-server/src/public_tests.rs | 18 +++++++++++++++- crates/collab-net/src/limits.rs | 6 ++++-- docs/server.md | 4 ++-- 4 files changed, 38 insertions(+), 16 deletions(-) diff --git a/apps/figcraft-server/src/config.rs b/apps/figcraft-server/src/config.rs index bd587b76..3e951e87 100644 --- a/apps/figcraft-server/src/config.rs +++ b/apps/figcraft-server/src/config.rs @@ -253,8 +253,9 @@ impl Config { } } - /// Largest message a client may send when open to the public: a whole file (or plugin bundle) - /// and a margin, not the core's 64 MiB. + /// Largest message a client may send: a whole file (or plugin bundle) and a margin. Open to the + /// public this is also the cap (strangers' files stay small); a private server never goes below + /// the core's 64 MiB, and a `max_file_mb` above it raises the cap with it. fn open_message(&self) -> usize { usize::try_from(self.file_mb().max(self.max_plugin_mb).saturating_add(1).saturating_mul(1 << 20)).unwrap_or(usize::MAX) } @@ -265,9 +266,9 @@ impl Config { fn core(&self) -> figcraft_collab::Limits { let base = figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() }; if !self.open_publish { - return base; + return figcraft_collab::Limits { max_message_bytes: base.max_message_bytes.max(self.open_message()), ..base }; } - let max = self.open_message().min(base.max_message_bytes); + let max = self.open_message(); let file = usize::try_from(self.file_mb().saturating_mul(1 << 20)).unwrap_or(usize::MAX); figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: (file / 4).max(256 << 10), ..base } } @@ -279,21 +280,24 @@ impl Config { pub fn net(&self) -> NetLimits { let base = NetLimits { max_connections: self.max_connections, trusted_proxy: self.proxy().ok().flatten(), ..NetLimits::default() }; if !self.open_publish { - return base; + let max = base.max_client_message.max(self.open_message()); + return NetLimits { max_client_message: max, max_queued_bytes: base.max_queued_bytes.max(max.saturating_add(1 << 20)), ..base }; } - let max = self.open_message().min(base.max_client_message); + let max = self.open_message(); // Shared budgets: one block of addresses (an IPv4 address, an IPv6 /48) gets at most an - // eighth of each, and its biggest consumers are dropped first. - let (queued, reading) = (256usize << 20, 192usize << 20); + // eighth of each, and its biggest consumers are dropped first. Each still fits one whole + // message, or a file as large as `max_file_mb` could never arrive. + let one = max.saturating_add(1 << 20); + let (queued, reading) = ((256usize << 20).max(one.saturating_mul(8)), (192usize << 20).max(one.saturating_mul(8))); NetLimits { max_per_address: self.max_connections_per_ip, max_client_message: max, - max_queued_bytes: max.saturating_add(1 << 20), + max_queued_bytes: one, max_queued_block: queued / 8, max_queued_total: queued, - max_reading_block: max.saturating_add(1 << 20).min(reading / 8), + max_reading_block: one.min(reading / 8), max_reading_total: reading, - max_in_flight: 128 << 20, + max_in_flight: (128usize << 20).max(one), reserve_for_sessions: self.max_connections / 8, request_idle: std::time::Duration::from_secs(20), ..base diff --git a/apps/figcraft-server/src/public_tests.rs b/apps/figcraft-server/src/public_tests.rs index fea2667e..d93dbe3e 100644 --- a/apps/figcraft-server/src/public_tests.rs +++ b/apps/figcraft-server/src/public_tests.rs @@ -413,7 +413,7 @@ fn open_mode_has_safe_defaults_and_warns_the_operator() { assert_eq!(open.net().request_idle, Duration::from_secs(20), "idle one-shot requests go quickly"); assert_eq!(open.net().reserve_for_sessions, 512 / 8); assert_eq!(open.net().max_client_message, 17 << 20); - assert_eq!(private.net().max_client_message, NetLimits::default().max_client_message, "unchanged when private"); + assert_eq!(private.net().max_client_message, 65 << 20, "private: a 64 MiB file and a margin"); let w = open.warnings().join("\n"); assert!(w.contains("operator_contact is empty") && w.contains("trusted_proxy"), "{w}"); let set = Config { operator_contact: "abuse@example.com".into(), trusted_proxy: Some("127.0.0.1".into()), max_file_mb: Some(8), ..open }; @@ -493,3 +493,19 @@ fn open_connections_never_reach_what_needs_the_key() { assert!(!format!("{info:?}").contains(&k)); server.stop(); } + +#[test] +fn files_above_64_mib_fit_every_limit() { + // `max_file_mb` may go up to 4096: every cap a whole file's message meets on its way in must + // follow it, or the upload is dropped by the transport before the server can say why. + for open_publish in [false, true] { + let cfg = Config { open_publish, max_file_mb: Some(256), public_url: Some("wss://figcraft.example.com/".into()), ..Config::default() }; + let (net, core, file) = (cfg.net(), cfg.store().core, 256usize << 20); + assert!(net.max_client_message > file, "open={open_publish}: {}", net.max_client_message); + assert!(net.max_queued_bytes > file && net.max_reading_block > file && net.max_in_flight > file, "open={open_publish}: {net:?}"); + assert!(net.max_reading_total >= net.max_reading_block && net.max_queued_total >= net.max_queued_block); + assert!(core.max_message_bytes > file, "open={open_publish}: {}", core.max_message_bytes); + } + // A client takes the largest file any server can keep. + assert!(NetLimits::default().max_server_message > 4096 << 20); +} diff --git a/crates/collab-net/src/limits.rs b/crates/collab-net/src/limits.rs index c3111876..42756b1e 100644 --- a/crates/collab-net/src/limits.rs +++ b/crates/collab-net/src/limits.rs @@ -12,7 +12,9 @@ pub struct NetLimits { /// before: its hello. pub max_client_message: usize, pub max_hello: usize, - /// Largest message a client accepts from the host (a welcome carries the whole document). + /// Largest message a client accepts from the host (a welcome carries the whole document): the + /// largest file a `figcraft-server` can be set to keep (`max_file_mb`, at most 4096) and a margin, + /// so a client can open every file a server takes. pub max_server_message: usize, /// Messages a client may send per second on average, and in a burst; beyond that it is /// disconnected. @@ -61,7 +63,7 @@ impl Default for NetLimits { max_connections: 64, max_client_message: figcraft_collab::Limits::default().max_message_bytes, max_hello: 64 << 10, - max_server_message: figcraft_collab::Limits::default().max_message_bytes, + max_server_message: 4097 << 20, rate: 100.0, burst: 400.0, hello_timeout: Duration::from_secs(10), diff --git a/docs/server.md b/docs/server.md index f2e4820c..55d784f4 100644 --- a/docs/server.md +++ b/docs/server.md @@ -282,7 +282,7 @@ are errors): | `data` | `figcraft-data` | The data directory | | `public_url` | (the listen address) | How links name the server, when people reach it another way (a reverse proxy: `wss://figcraft.example.com/`) | | `max_files` | 1000 | Most files kept | -| `max_file_mb` | 64 (16 when open) | Largest file (its snapshot), in MiB; a welcome carries the whole file, and messages are at most 64 MiB | +| `max_file_mb` | 64 (16 when open) | Largest file (its snapshot), in MiB; a welcome carries the whole file, so messages may be as large as a file and 1 MiB (at least 64 MiB on a private server) | | `max_links` | 100 | Most share links per file | | `max_connections` | 512 | Most open connections | | `max_people` | 256 | Most people in one file at once | @@ -489,7 +489,7 @@ are larger. | Resource | Limit | |---|---| -| Message size | A file's size and 1 MiB (`max_file_mb`, 16 by default when open, or `max_plugin_mb` if larger): checked as the message is read, before anything is decoded; 64 KiB on a connection that only browses plugins (a client sending more is disconnected with the limit as the reason; the app keeps its own copy). The core's 64 MiB otherwise | +| Message size | A file's size and 1 MiB (`max_file_mb`, 16 by default when open, or `max_plugin_mb` if larger): checked as the message is read, before anything is decoded; 64 KiB on a connection that only browses plugins (a client sending more is disconnected with the limit as the reason; the app keeps its own copy). On a private server, the larger of that and the core's 64 MiB | | A file as it grows | Every batch is checked first (*always*): past `max_file_mb`, past its publisher's `max_mb_per_ip` or past `max_total_mb`, it is refused with the reason and the app rolls it back. A long log is compacted to make room at most once a minute per file (a compaction rewrites the whole file). A file's snapshot also holds the recent batches kept for reconnecting clients, at most a quarter of `max_file_mb` | | Recent batches in memory | A quarter of `max_file_mb` (256 KiB at least) per open file; older ones are dropped (a client that missed them catches up with the whole file) | | Bytes queued for slow clients | The largest message and 1 MiB per connection, 32 MiB per block of addresses, 256 MiB for everyone. A message sent to many people counts once for everyone and once per block (twenty colleagues behind one office address cost their block one copy of a broadcast), and welcomes (bounded by the file's size) don't count against blocks, so a whole office can join a file at once. A client that doesn't read its answers is dropped; when everyone's budget runs out, the connection holding the most is dropped | From 3b5c5d563b18c2d329ed2f4e92b8c3de87071d72 Mon Sep 17 00:00:00 2001 From: Ahmed5Emad Date: Fri, 16 Oct 2026 23:27:45 +0300 Subject: [PATCH 051/118] Palette follows the bar: toggle, dismiss, no invented icons The Actions button toggles (re-click and Cmd+K close), reads on in accent blue with a white icon while open, and a press anywhere outside closes the panel in one click. The opening press is spared once (the spare expires on press-free frames); presses on the button itself never dismiss, so the release toggles cleanly shut instead of reopening. Rows drop the per-family icons (labels + right-side hints only); plugins and widgets keep their letter tiles. The panel anchors to the canvas centre at the toolbar pill's exact width (toolbar::main_width), at a fixed size, with inset hover pills and larger section titles. Tests: one-click dismiss pinned (palette_tests). --- crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/lib.rs | 15 ++++++-- crates/ui-egui/src/menus.rs | 22 +++++++++--- crates/ui-egui/src/palette_tests.rs | 53 ++++++++++++++++++++++++++++- crates/ui-egui/src/toolbar.rs | 20 +++++++++-- 5 files changed, 101 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 9dd8e2f4..6e55c8a4 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -416,6 +416,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul if let Some(h) = p.get("palette").and_then(Value::as_bool) { app.ui.palette = if h { Some(String::new()) } else { None }; app.ui.palette_tab = crate::menus::PaletteTab::All; + app.ui.palette_armed = true; } // true / false, or a top-level menu to open it at ("Plugins"). match p.get("mainMenu") { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 46830316..34f6ac0d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -408,6 +408,10 @@ pub struct UiState { pub palette: Option, /// The Actions palette's tab. pub palette_tab: menus::PaletteTab, + /// The palette ignores one outside click after opening (the click that opened it). + pub palette_armed: bool, + /// This frame's Actions toolbar button rect: presses on it toggle, never dismiss. + pub actions_rect: Option, /// The Variables editor, when open. pub variables: Option, pub about: bool, @@ -538,6 +542,8 @@ impl Default for UiState { main_menu: false, palette: None, palette_tab: menus::PaletteTab::All, + palette_armed: false, + actions_rect: None, variables: None, about: false, about_tab: dialogs::AboutTab::About, @@ -794,8 +800,13 @@ impl FigApp { "dialog.preferences" => self.ui.preferences = true, "panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel, "palette.actions" => { - self.ui.palette = Some(String::new()); - self.ui.palette_tab = menus::PaletteTab::All; + if self.ui.palette.is_some() { + self.ui.palette = None; + } else { + self.ui.palette = Some(String::new()); + self.ui.palette_tab = menus::PaletteTab::All; + self.ui.palette_armed = true; + } } "panel.design" => self.ui.right_tab = RightTab::Design, "panel.prototype" => self.ui.right_tab = RightTab::Prototype, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 0e079313..a95fe2fd 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -651,6 +651,18 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) { ); let mut run: Option = None; let mut close = false; + // A press anywhere outside closes the panel. The opening press is spared once, and the + // spare expires the first frame without a press (so the next outside press always closes). + let pressed = ctx.input(|i| i.pointer.any_pressed()); + if pressed && !std::mem::replace(&mut app.ui.palette_armed, false) { + let on_toggle = app.ui.actions_rect.is_some_and(|b| ctx.input(|i| i.pointer.hover_pos().is_some_and(|p| b.contains(p)))); + let outside = !on_toggle && ctx.input(|i| i.pointer.hover_pos().map(|p| !area.contains(p)).unwrap_or(false)); + if outside { + close = true; + } + } else if !pressed { + app.ui.palette_armed = false; + } egui::Area::new(Id::new("palette")).fixed_pos(area.min).order(egui::Order::Foreground).show( ctx, |ui| { @@ -830,9 +842,9 @@ pub(crate) struct ActionRow { /// A section header with its rows. fn section(ui: &mut Ui, t: &Tokens, name: &str, rows: impl FnOnce(&mut Ui)) { - ui.add_space(6.0); - ui.painter().text(pos2(ui.min_rect().min.x + 10.0, ui.cursor().min.y + 7.0), Align2::LEFT_TOP, name, regular(11.0), t.text_secondary); - ui.add_space(18.0); + ui.add_space(10.0); + ui.painter().text(pos2(ui.min_rect().min.x + 10.0, ui.cursor().min.y + 8.0), Align2::LEFT_TOP, name, regular(12.5), t.text_secondary); + ui.add_space(22.0); rows(ui); } @@ -841,8 +853,10 @@ fn section(ui: &mut Ui, t: &Tokens, name: &str, rows: impl FnOnce(&mut Ui)) { fn draw_row(ui: &mut Ui, t: &Tokens, row: &ActionRow) -> bool { let w = ui.available_width(); let (r, resp) = ui.allocate_exact_size(vec2(w, 34.0), Sense::click()); + // The hover pill stays inside the row: it never touches the panel's edges. + let pill = r.shrink2(vec2(4.0, 1.0)); if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(6), t.hover); + ui.painter().rect_filled(pill, CornerRadius::same(6), t.hover); } let mut label_x = r.min.x + 10.0; if let Some(letter) = row.tile { diff --git a/crates/ui-egui/src/palette_tests.rs b/crates/ui-egui/src/palette_tests.rs index e496cb77..06e9fdf5 100644 --- a/crates/ui-egui/src/palette_tests.rs +++ b/crates/ui-egui/src/palette_tests.rs @@ -1,7 +1,10 @@ //! The Actions palette, driven headless: invoking records recents (newest first, capped), -//! sections group commands by menu, the query filters, and gone entries drop out of Recents. +//! sections group commands by menu, the query filters, gone entries drop out of Recents, a used +//! plugin resolves back to a row, and a press outside the panel closes it. +use egui::Pos2; use figcraft_engine::Session; +use serde_json::json; use crate::layers_drag_tests::Ui; @@ -58,3 +61,51 @@ fn gone_entries_drop_out_of_recents() { assert!(crate::menus::recent_row(&u.app, "plugin:nope.nope/nope", "").is_none(), "gone plugins drop out"); assert!(crate::menus::recent_row(&u.app, "widget:nope", "").is_none(), "gone widgets drop out"); } + +/// A used plugin (API 4, several commands) resolves back to a Recents row. +#[test] +fn used_plugin_resolves_to_a_recent_row() { + let mut u = Ui::new(Session::new()); + let plugin: figcraft_engine::plugins::InstalledPlugin = serde_json::from_value(json!({ + "manifest": { + "id": "dev.local.solar-icons", + "name": "Solar icons", + "main": "solar_icons.wasm", + "api": 4, + "commands": [ + {"id": "panel", "label": "Panel"}, + {"id": "grid", "label": "Grid"}, + ], + }, + })) + .unwrap(); + u.app.session.prefs.plugins.push(plugin); + crate::menus::record_recent(&mut u.app, "plugin:dev.local.solar-icons/panel"); + let row = crate::menus::recent_row(&u.app, "plugin:dev.local.solar-icons/panel", "") + .expect("a used plugin stays in Recents"); + assert_eq!(row.label, "Solar icons — Panel"); + assert!(crate::menus::recent_row(&u.app, "plugin:dev.local.solar-icons/panel", "zzz").is_none(), "the query filters recents"); +} + +/// A press outside the panel closes it; the opening press is spared once. +#[test] +fn outside_press_closes_the_panel() { + let mut u = Ui::new(Session::new()); + u.app.ui.palette = Some(String::new()); + u.app.ui.palette_armed = true; + // The opening press: spared, and disarmed. + u.button(Pos2::new(720.0, 20.0), true); + u.button(Pos2::new(720.0, 20.0), false); + assert!(u.app.ui.palette.is_some(), "the opening press is spared"); + // The next press outside: closed. + u.button(Pos2::new(720.0, 20.0), true); + u.button(Pos2::new(720.0, 20.0), false); + assert!(u.app.ui.palette.is_none(), "an outside press closes the panel"); + // One click is enough: the spare expires on frames without a press. + u.app.ui.palette = Some(String::new()); + u.app.ui.palette_armed = true; + u.frame(vec![]); + u.button(Pos2::new(720.0, 20.0), true); + u.button(Pos2::new(720.0, 20.0), false); + assert!(u.app.ui.palette.is_none(), "a single outside press closes the panel"); +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 0aef7555..962b7497 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -57,6 +57,7 @@ fn remembered(ui: &Ui, key: &str, default: ToolId) -> ToolId { pub fn show(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let canvas = app.canvas.rect; + app.ui.actions_rect = None; // A file you can't edit now (view only, or on its way to a server): moving around only. if let Some(why) = app.session.active_read_only() { view_only_toolbar(app, ui, why); @@ -138,13 +139,26 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { ] { let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell)); let resp = ui.interact(r, ui.id().with(id), Sense::click()).tip(tip); - if resp.hovered() { + if id == "actions" { + app.ui.actions_rect = Some(r); + } + // The Actions button reads on while its panel is open; clicking it again closes it. + let actions_open = id == "actions" && app.ui.palette.is_some(); + if actions_open { + ui.painter().rect_filled(r, CornerRadius::same(5), ACCENT); + } else if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } - icons::paint(ui.painter(), r.shrink(7.0), icon, t.icon); + icons::paint(ui.painter(), r.shrink(7.0), icon, if actions_open { Color32::WHITE } else { t.icon }); if resp.clicked() { if id == "actions" { - app.ui.palette = Some(String::new()); + if app.ui.palette.is_some() { + app.ui.palette = None; + } else { + app.ui.palette = Some(String::new()); + app.ui.palette_tab = crate::menus::PaletteTab::All; + app.ui.palette_armed = true; + } } else { app.ui.left_tab = crate::LeftTab::Assets; app.ui.left_collapsed = false; From 1b4db719576965d13e8ec39e319ef2e4ae67dc85 Mon Sep 17 00:00:00 2001 From: sushi-killer <46325863+sushi-killer@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:37:55 +0300 Subject: [PATCH 052/118] fix(collab-net): keep the client's message cap in range on 32-bit targets --- apps/figcraft-server/src/public_tests.rs | 6 ++++-- crates/collab-net/src/limits.rs | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/figcraft-server/src/public_tests.rs b/apps/figcraft-server/src/public_tests.rs index d93dbe3e..6403212c 100644 --- a/apps/figcraft-server/src/public_tests.rs +++ b/apps/figcraft-server/src/public_tests.rs @@ -506,6 +506,8 @@ fn files_above_64_mib_fit_every_limit() { assert!(net.max_reading_total >= net.max_reading_block && net.max_queued_total >= net.max_queued_block); assert!(core.max_message_bytes > file, "open={open_publish}: {}", core.max_message_bytes); } - // A client takes the largest file any server can keep. - assert!(NetLimits::default().max_server_message > 4096 << 20); + // A client takes the largest file any server can keep, or as much as a 32-bit target can address + // (computed in u64: `4097 << 20` in a 32-bit usize wraps to 1 MiB). + let client = NetLimits::default().max_server_message as u64; + assert!(client > 4096 << 20 || client == u64::from(u32::MAX), "{client}"); } diff --git a/crates/collab-net/src/limits.rs b/crates/collab-net/src/limits.rs index 42756b1e..02167770 100644 --- a/crates/collab-net/src/limits.rs +++ b/crates/collab-net/src/limits.rs @@ -14,7 +14,8 @@ pub struct NetLimits { pub max_hello: usize, /// Largest message a client accepts from the host (a welcome carries the whole document): the /// largest file a `figcraft-server` can be set to keep (`max_file_mb`, at most 4096) and a margin, - /// so a client can open every file a server takes. + /// so a client can open every file a server takes; on a 32-bit target (the browser) as much as + /// `usize` holds. pub max_server_message: usize, /// Messages a client may send per second on average, and in a burst; beyond that it is /// disconnected. @@ -63,7 +64,7 @@ impl Default for NetLimits { max_connections: 64, max_client_message: figcraft_collab::Limits::default().max_message_bytes, max_hello: 64 << 10, - max_server_message: 4097 << 20, + max_server_message: usize::try_from(4097u64 << 20).unwrap_or(usize::MAX), rate: 100.0, burst: 400.0, hello_timeout: Duration::from_secs(10), From 9e2596e97524951b6c93e038d5a03c252322fed2 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:09:53 +0000 Subject: [PATCH 053/118] Skip layers with children in vector.offset Found while testing crates/engine/src/cmd/structure.rs:575 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/structure.rs | 4 ++++ crates/engine/src/tests.rs | 38 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index bed70d3d..6829595c 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -556,6 +556,10 @@ fn offset_path(s: &mut Session, p: &Value) -> Result { for i in req_targets("vector.offset", s, p)? { let doc = s.doc_mut()?; let n = doc.get(i)?.clone(); + // A vector can't hold children: leave containers (frames, groups, instances) alone. + if !n.children.is_empty() { + continue; + } let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue }; let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() }; let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dbf432d4..746f417e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -621,3 +621,41 @@ fn find_replace_changing_only_the_case_replaces_every_occurrence() { assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft"); assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft"); } + +/// No node that holds children may be a non-container. +fn assert_children_only_in_containers(s: &Session, ids: &[NodeId]) { + let doc = s.doc().unwrap(); + for &i in ids { + let n = doc.node(i).unwrap(); + assert!(n.kind.is_container() || n.kind.is_frame_like() || n.children.is_empty(), "a non-container layer now holds children"); + } +} + +#[test] +fn offset_path_leaves_containers_with_children_alone() { + let mut s = sess(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let inner = id(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10, "parent": f})).unwrap()); + let a = id(&s.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 10, "height": 10})).unwrap()); + let b = id(&s.execute("shape.rectangle", &json!({"x": 220, "y": 0, "width": 10, "height": 10})).unwrap()); + let g = id(&s.execute("object.group", &json!({"ids": [a, b]})).unwrap()); + for target in [f, g] { + s.execute("vector.offset", &json!({"ids": [target], "offset": 5})).unwrap(); + assert_children_only_in_containers(&s, &[f, g, inner, a, b]); + } + let doc = s.doc().unwrap(); + assert!(doc.node(f).unwrap().kind.is_container()); + assert_eq!(doc.node(f).unwrap().children, vec![inner]); + assert!(doc.node(g).unwrap().kind.is_container()); + assert_eq!(doc.node(g).unwrap().children.len(), 2); +} + +#[test] +fn offset_path_still_converts_a_plain_rectangle() { + let mut s = sess(); + let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + s.execute("vector.offset", &json!({"ids": [r], "offset": 5})).unwrap(); + let n = s.doc().unwrap().node(r).unwrap().clone(); + assert!(matches!(n.kind, NodeKind::Vector { .. })); + assert!(n.width > 10.0); +} From a9bcc48df7bc96f8428c312b77aff9c53740e36b Mon Sep 17 00:00:00 2001 From: sushi-killer <46325863+sushi-killer@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:45:08 +0300 Subject: [PATCH 054/118] feat(web): join a live session from ?join= in the page URL --- apps/figcraft-web/src/main.rs | 9 ++++++++- apps/figcraft-web/src/query.rs | 10 ++++++++++ apps/figcraft-web/src/query_tests.rs | 21 +++++++++++++++++++++ apps/figcraft-web/src/web.rs | 8 +++++++- 4 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 apps/figcraft-web/src/query.rs create mode 100644 apps/figcraft-web/src/query_tests.rs diff --git a/apps/figcraft-web/src/main.rs b/apps/figcraft-web/src/main.rs index 587fd60b..35aebb02 100644 --- a/apps/figcraft-web/src/main.rs +++ b/apps/figcraft-web/src/main.rs @@ -13,8 +13,15 @@ //! (browsers block `ws://` from secure pages); serve it over `http://` on a trusted network to //! join from other machines. //! -//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU. +//! URL query: `?webgl` forces the WebGL2 backend instead of WebGPU; `?join=` joins that live session once the page has loaded, so a bookmark opens a shared file. +//! `join` takes the rest of the query: put it last. The link carries the session's secret, so the +//! URL (and the browser history) is as private as the link itself. +#[cfg(any(target_arch = "wasm32", test))] +mod query; +#[cfg(test)] +mod query_tests; #[cfg(target_arch = "wasm32")] mod web; diff --git a/apps/figcraft-web/src/query.rs b/apps/figcraft-web/src/query.rs new file mode 100644 index 00000000..be68f598 --- /dev/null +++ b/apps/figcraft-web/src/query.rs @@ -0,0 +1,10 @@ +//! The page's URL query: which flags it sets and the invite link it names. + +/// The invite link in `?join=` (`search` is `location.search`, with or without its `?`), still +/// escaped. `join` takes the rest of the query, so a pasted `figcraft://join?host=…&file=…&token=…` +/// keeps its own `&`s; put it last. +pub fn join_link(search: &str) -> Option<&str> { + let query = search.strip_prefix('?').unwrap_or(search); + let start = if query.starts_with("join=") { 0 } else { query.find("&join=")? + 1 }; + query.get(start + "join=".len()..).filter(|link| !link.is_empty()) +} diff --git a/apps/figcraft-web/src/query_tests.rs b/apps/figcraft-web/src/query_tests.rs new file mode 100644 index 00000000..9582682f --- /dev/null +++ b/apps/figcraft-web/src/query_tests.rs @@ -0,0 +1,21 @@ +//! `?join=` in the page's URL. + +use crate::query::join_link; + +#[test] +fn join_takes_the_rest_of_the_query() { + let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv"; + assert_eq!(join_link(&format!("?join={link}")), Some(link), "a pasted link keeps its own &s"); + assert_eq!(join_link(&format!("?webgl&join={link}")), Some(link), "after other flags"); + assert_eq!(join_link("?join=figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), Some("figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), "escaped, decoded later"); + assert_eq!(join_link("join=K7M-2QX-9TD"), Some("K7M-2QX-9TD"), "a join code, no leading ?"); +} + +#[test] +fn no_join_without_the_parameter() { + assert_eq!(join_link(""), None); + assert_eq!(join_link("?webgl"), None); + assert_eq!(join_link("?join="), None); + assert_eq!(join_link("?rejoin=x"), None, "only the whole parameter name"); + assert_eq!(join_link("?xjoin=x&a=1"), None); +} diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 8cadb013..d3c16441 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -40,7 +40,13 @@ pub fn start() { // on the socket wakes the page for a frame. let ctx = cc.egui_ctx.clone(); session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint())))); - let fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() })); + let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() })); + // `?join=`: join that session, as opening an invite link does on the desktop. + let search = query(); + if let Some(link) = crate::query::join_link(&search) { + let link = js_sys::decode_uri_component(link).ok().and_then(|s| s.as_string()).unwrap_or_else(|| link.to_string()); + figcraft_ui_egui::live::open_invite(&mut fig, &link); + } Ok(Box::new(WebShell { fig })) }), ) From ce472a251cf8e739ce2756594600fb8ca030e30f Mon Sep 17 00:00:00 2001 From: Ismael Trejo Date: Fri, 16 Oct 2026 16:40:27 -0700 Subject: [PATCH 055/118] fix(doc): thin strokes take a double-click a few pixels away (#646) A 1 px Line or stroke-only path only counted as hit within 2 screen pixels, so a double-click slightly off it cleared the selection and never entered vector edit mode, and Cmd/Ctrl could not bend it. Strokes with nothing filled now reach 5 screen pixels; filled shapes keep the plain tolerance. --- crates/doc/src/geometry.rs | 10 ++++- crates/engine/src/vector_behaviour_tests.rs | 44 +++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index 06596ddc..2a7b42a6 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -51,6 +51,10 @@ pub fn is_closed(n: &Node) -> bool { } } +/// How much further than `tol` a stroke with nothing filled reaches: a thin line or stroke-only path +/// is hard to land on with the plain tolerance, so it gets a wider band (5 screen px for the usual 2 px tolerance). +const STROKE_REACH: f64 = 2.5; + /// Whether `pt` hits `n` itself; in `outline` mode (nothing filled on the canvas) a shape only where /// its outline is. fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, outline: bool) -> bool { @@ -62,7 +66,7 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o NodeKind::Group | NodeKind::BooleanOp { .. } | NodeKind::Page { .. } => false, NodeKind::Text(_) | NodeKind::Slice => n.local_rect().inflate(tol_l, tol_l).contains(lp), NodeKind::Line => { - let half = (n.stroke.weight / 2.0).max(tol_l); + let half = (n.stroke.weight / 2.0).max(tol_l * STROKE_REACH); lp.x >= -half && lp.x <= n.width + half && lp.y.abs() <= half } NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => { @@ -78,7 +82,9 @@ fn hits_path(n: &Node, path: &BezPath, lp: Point, tol: f64, inside: bool) -> boo if inside && (n.fills.iter().any(|f| f.visible) || !n.has_visible_stroke()) { return true; } - let reach = (n.stroke.max_weight() / 2.0).max(tol); + // Only a stroke with nothing filled gets the wider band: a filled shape's edge keeps the plain one. + let stroke_only = n.has_visible_stroke() && !n.fills.iter().any(|f| f.visible); + let reach = (n.stroke.max_weight() / 2.0).max(if stroke_only { tol * STROKE_REACH } else { tol }); if !path.bounding_box().inflate(reach, reach).contains(lp) { return false; } diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index 0c472ecc..8899dc4e 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -133,6 +133,50 @@ fn entering_and_leaving_vector_edit_mode() { assert!(matches!(s.doc().unwrap().node(id).unwrap().kind, NodeKind::Rectangle), "converting is one undo step"); } +#[test] +fn thin_strokes_are_easy_to_enter_and_bend() { + use crate::tools::ToolId; + // A diagonal line from (100, 100) to (200, 50); `off` steps away from its middle, square to it. + let at = |off: f64| (150.0 + off * 50.0 / 125f64.sqrt() / 10.0, 75.0 + off * 100.0 / 125f64.sqrt() / 10.0); + // (layer, screen px off the stroke, in edit mode after a double-click, derivation) + let rows: Vec<(&str, f64, bool, &str)> = vec![ + ("line", 0.0, true, "double-clicking a selected line edits it as a vector"), + ("line", 4.0, true, "a 1 px line reaches a few pixels, so a double-click near it still lands (#646)"), + ("line", 8.0, false, "a double-click well away from it does not"), + ("path", 4.0, true, "a stroke-only path reaches as far as a line does"), + ("path", 8.0, false, "and no further"), + ]; + for (layer, off, after, why) in rows { + let mut s = Session::new(); + let id = if layer == "line" { + s.set_tool(ToolId::Line); + drag(&mut s, (100.0, 100.0), (200.0, 50.0), NONE); + *s.selection().first().unwrap() + } else { + let id = id_of(&ex(&mut s, "vector.create", json!({"points": [[100, 100], [200, 50]], "closed": false}))["id"]).unwrap(); + // Styled like a pen stroke: 1 px, no fill. + ex(&mut s, "node.set", json!({"ids": [id], "props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 1}})); + s.set_selection(vec![id]); + id + }; + assert!(!s.doc().unwrap().node(id).unwrap().fills.iter().any(|f| f.visible), "{why}: the {layer} only has a stroke"); + let p = at(off); + click(&mut s, p, NONE); + s.pointer(&ev(PointerKind::Down, p, NONE)).unwrap(); + s.pointer(&ev(PointerKind::DoubleClick, p, NONE)).unwrap(); + s.pointer(&ev(PointerKind::Up, p, NONE)).unwrap(); + assert_eq!(editing(&s).is_some(), after, "{why}"); + if after { + // Cmd/Ctrl then bends it through the pointer. + drag(&mut s, at(0.0), (170.0, 115.0), Mods { cmd: true, ..NONE }); + let w = s.doc().unwrap().world_transform(id); + let n = net(&s, id); + assert!(!n.segments[0].is_straight(), "{why}: Cmd/Ctrl bends it"); + assert!((w * n.cubic(0).unwrap().eval(0.5) - Point::new(170.0, 115.0)).hypot() < 1.0, "{why}: through the pointer"); + } + } +} + #[test] fn the_pen_follows_the_documented_rules() { let mut s = Session::new(); From 9f52819fe9877421876fe42e401ceed7c3c97fc6 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Sat, 17 Oct 2026 08:35:03 +0700 Subject: [PATCH 056/118] fix(ui): Shift+Cmd/Ctrl+V pastes over the selection and Alt+Cmd/Ctrl+V pastes properties (#716) egui-winit swallows every Cmd/Ctrl+V press, Shift or Alt held or not, and sends a paste event only for text. Shift+Cmd+V and Alt+Cmd+V became a plain paste (text) or did nothing (image). The keymap now decides what a swallowed chord means, from the modifiers tracked in the raw events: its paste event or its release runs paste over or paste properties instead. In the browser the key press runs the command, and the paste event no longer pastes its text as well. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/lib.rs | 7 +- crates/ui-egui/src/paste_image_tests.rs | 152 ++++++++++++++++++++++ crates/ui-egui/src/shortcuts.rs | 73 ++++++++--- crates/ui-egui/src/web_clipboard_tests.rs | 66 ++++++++++ 4 files changed, 279 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9fbd17cc..a64ef6a8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -502,6 +502,8 @@ pub struct UiState { pub paste_event: bool, /// The window got the focus back since the last V release. pub refocused: bool, + /// The modifiers held, as of the last raw event that told: a paste event doesn't carry them. + pub held: egui::Modifiers, /// When (UI clock, seconds) open documents next get an autosave checkpoint. pub next_autosave: f64, /// With auto-save on: when (UI clock, seconds) files with unsaved changes are next saved, and @@ -598,6 +600,7 @@ impl Default for UiState { v_pressed: false, paste_event: false, refocused: false, + held: egui::Modifiers::NONE, next_autosave: 0.0, auto_save_at: None, auto_saving: Vec::new(), @@ -1294,7 +1297,7 @@ impl FigApp { // even if it lets go during the frame (Enter commits it), or as it began (egui drops the // focus on Escape before the frame, which cancels the field's edit and nothing else). let typing = std::mem::take(&mut self.field_focus) || a11y::field_focused(&ctx); - let paste_release = shortcuts::swallowed_paste(self, &ctx); + let swallowed = shortcuts::swallowed_paste(self, &ctx); // A dialog that closed acts first, in the window it was opened from. self.poll_dialog(&ctx); // Control requests run first so their results reflect this frame. @@ -1410,7 +1413,7 @@ impl FigApp { branch_ui::keys(self, &ctx); live::dialog_keys(self, &ctx); dialogs::keys(self, &ctx); - shortcuts::handle(self, &ctx, typing, paste_release); + shortcuts::handle(self, &ctx, typing, swallowed); menus::popups(self, &ctx); variable_ui::picker(self, &ctx); style_picker::show(self, &ctx); diff --git a/crates/ui-egui/src/paste_image_tests.rs b/crates/ui-egui/src/paste_image_tests.rs index fa797fb9..23cec8ea 100644 --- a/crates/ui-egui/src/paste_image_tests.rs +++ b/crates/ui-egui/src/paste_image_tests.rs @@ -6,11 +6,25 @@ use std::cell::RefCell; use std::rc::Rc; use egui::{Event, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, Shape, vec2}; +use figcraft_doc::NodeId; use figcraft_engine::Session; use serde_json::json; use crate::{FigApp, Services}; +/// What the system clipboard holds when a paste chord is pressed (#716). +#[derive(Clone, Copy, Debug)] +enum Held { + /// FigCraft's own copy (its SVG text). + Ours, + /// Text copied in another app. + Text, + /// An image copied in another app. + Image, +} + +const HELD: [Held; 3] = [Held::Ours, Held::Text, Held::Image]; + #[derive(Default)] struct Clip { text: Option, @@ -87,6 +101,55 @@ impl Win { self.frame(vec![]); } + /// A Cmd/Ctrl+V chord with its modifiers, as egui-winit delivers it: they go down first, the V + /// press brings a paste event only for text, its release comes as a key event, then they're + /// let go. + fn chord(&mut self, m: Modifiers) { + let text = self.clip.borrow().text.clone().filter(|t| !t.is_empty()); + self.frame(vec![Event::ModifiersChanged(m)]); + self.frame(text.map(Event::Paste).into_iter().collect()); + self.frame(vec![key(Key::V, false, m)]); + self.frame(vec![Event::ModifiersChanged(Modifiers::NONE)]); + } + + /// A red rectangle A copied in FigCraft, and a grey rectangle B selected; then `held` on the + /// system clipboard. Returns (A, B). + fn copy_a_select_b(&mut self, held: Held) -> (NodeId, NodeId) { + let s = &mut self.app.session; + let a = s.doc().unwrap().nodes().find(|n| n.name == "Rectangle").unwrap().id; + s.execute("fill.set", &json!({"ids": [a.0], "color": "#e53935"})).unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 400, "y": 250, "width": 100, "height": 50})).unwrap(); + let b = NodeId(b["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + crate::menus::invoke(&mut self.app, "edit.copy"); + self.app.session.execute("edit.copyProperties", &json!({})).unwrap(); + self.app.session.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + match held { + Held::Ours => {} + Held::Text => self.clip.borrow_mut().text = Some("hello from another app".into()), + Held::Image => self.copy_image_elsewhere(), + } + (a, b) + } + + fn fill(&self, id: NodeId) -> String { + let n = figcraft_engine::inspect::node_json(self.app.session.doc().unwrap(), id, 0); + n["fills"][0]["color"].as_str().unwrap_or_default().to_string() + } + + /// Top-lefts of the rectangles other than `b`. + fn rects_besides(&self, b: NodeId) -> Vec<(i64, i64)> { + let doc = self.app.session.doc().unwrap(); + let mut at: Vec<_> = doc + .nodes() + .filter(|n| n.name == "Rectangle" && n.id != b) + .filter_map(|n| doc.world_bounds(n.id)) + .map(|r| (r.x0.round() as i64, r.y0.round() as i64)) + .collect(); + at.sort(); + at + } + fn named(&self, name: &str) -> usize { self.app.session.doc().unwrap().nodes().filter(|n| n.name == name).count() } @@ -256,3 +319,92 @@ fn paste_here_prefers_a_newer_image_over_copied_layers() { let b = doc.world_bounds(img.id).unwrap(); assert!((b.x0 - p.x).abs() < 0.01 && (b.y0 - p.y).abs() < 0.01, "top-left where the menu opened: {b:?} vs {p:?}"); } + +#[test] +fn shift_cmd_v_pastes_over_the_selection_whatever_the_clipboard_holds() { + for held in HELD { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(held); + w.chord(CMD.plus(Modifiers::SHIFT)); + assert_eq!(w.rects_besides(b), [(10, 10), (400, 250)], "{held:?}: A's copy at B's top-left, nothing else"); + assert_eq!(w.texts(), 0, "{held:?}: no text pasted"); + assert_eq!(w.named("Pasted image"), 0, "{held:?}: no image pasted"); + } +} + +#[test] +fn alt_cmd_v_pastes_properties_whatever_the_clipboard_holds() { + for held in HELD { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(held); + w.chord(CMD.plus(Modifiers::ALT)); + assert_eq!(w.fill(b), "#E53935", "{held:?}: B took A's fill"); + assert_eq!(w.rects_besides(b), [(10, 10)], "{held:?}: no layer pasted"); + assert_eq!(w.texts(), 0, "{held:?}: no text pasted"); + assert_eq!(w.named("Pasted image"), 0, "{held:?}: no image pasted"); + } +} + +#[test] +fn a_chord_let_go_within_one_frame_still_reads_its_modifiers() { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(Held::Text); + let m = CMD.plus(Modifiers::SHIFT); + w.frame(vec![ + Event::ModifiersChanged(m), + Event::Paste("hello from another app".into()), + key(Key::V, false, m), + Event::ModifiersChanged(Modifiers::NONE), + ]); + w.frame(vec![]); + assert_eq!(w.rects_besides(b), [(10, 10), (400, 250)]); + assert_eq!(w.texts(), 0); +} + +#[test] +fn cmd_let_go_before_v_still_pastes_over() { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(Held::Image); + w.frame(vec![Event::ModifiersChanged(CMD.plus(Modifiers::SHIFT))]); + w.frame(vec![Event::ModifiersChanged(Modifiers::SHIFT)]); + w.frame(vec![key(Key::V, false, Modifiers::SHIFT)]); + w.frame(vec![Event::ModifiersChanged(Modifiers::NONE)]); + assert_eq!(w.rects_besides(b), [(10, 10), (400, 250)], "pasted over, not flipped or a plain paste"); + assert_eq!(w.named("Pasted image"), 0); +} + +#[test] +fn a_held_chord_pastes_over_once() { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(Held::Text); + let m = CMD.plus(Modifiers::SHIFT); + w.frame(vec![Event::ModifiersChanged(m)]); + // Auto-repeat: egui-winit sends a paste event for every repeated press. + for _ in 0..3 { + w.frame(vec![Event::Paste("hello from another app".into())]); + } + w.frame(vec![key(Key::V, false, m)]); + w.frame(vec![Event::ModifiersChanged(Modifiers::NONE)]); + assert_eq!(w.rects_besides(b), [(10, 10), (400, 250)]); + assert_eq!(w.texts(), 0); +} + +#[test] +fn shift_insert_still_pastes_text() { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(Held::Text); + w.frame(vec![Event::ModifiersChanged(Modifiers::SHIFT), Event::Paste("hello from another app".into())]); + w.frame(vec![key(Key::Insert, false, Modifiers::SHIFT), Event::ModifiersChanged(Modifiers::NONE)]); + assert_eq!(w.texts(), 1); + assert_eq!(w.rects_besides(b), [(10, 10)]); +} + +#[test] +fn cmd_v_after_a_paste_chord_is_a_plain_paste_again() { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(Held::Text); + w.chord(CMD.plus(Modifiers::SHIFT)); + w.chord(CMD); + assert_eq!(w.texts(), 1, "the text was pasted by the plain Cmd+V"); + assert_eq!(w.rects_besides(b), [(10, 10), (400, 250)], "Shift+Cmd+V pasted over once"); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index f28a2433..57295c22 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -94,8 +94,8 @@ fn cached_tip(label: &'static str, key: Option<&'static str>) -> &'static str { /// Run the shortcuts this frame's keys press. `typing`: a field had the keyboard when the frame /// began. -/// `paste_release`: this frame released a Cmd/Ctrl+V the shell swallowed ([`swallowed_paste`]). -pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, paste_release: bool) { +/// `swallowed`: what this frame's Cmd/Ctrl+V chords the shell swallowed ask for ([`swallowed_paste`]). +pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, swallowed: Swallowed) { note_platform(ctx); // Text fields in panels own the keyboard (also the key that ends their editing). if typing || crate::a11y::field_focused(ctx) || app.ui.palette.is_some() { @@ -113,16 +113,18 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, paste_release: bool // An image-only clipboard brings no paste event: paste from the key then. The browser sends // the press; the desktop shell swallows it and sends only the release. let paste_event = events.iter().any(|e| matches!(e, Event::Paste(_))); - let paste_key = events.iter().any(|e| matches!(e, Event::Key { key: Key::V, pressed: true, repeat: false, modifiers, .. } if modifiers.command && !modifiers.shift && !modifiers.alt)); - if (paste_key && !paste_event) || paste_release { - crate::menus::paste(app, None, None); + let paste_key = events.iter().any(|e| matches!(e, Event::Key { key: Key::V, pressed: true, repeat: false, modifiers, .. } if modifiers.command && v_binding(*modifiers) == Some("edit.paste"))); + match swallowed.run.or((paste_key && !paste_event).then_some("edit.paste")) { + Some("edit.paste") => crate::menus::paste(app, None, None), + Some(id) => run_binding(app, id), + None => {} } let now = ctx.input(|i| i.time); for ev in &events { match ev { Event::Copy => crate::menus::invoke(app, "edit.copy"), Event::Cut => crate::menus::invoke(app, "edit.cut"), - Event::Paste(text) => crate::menus::paste(app, Some(text), None), + Event::Paste(text) if !swallowed.drop_text => crate::menus::paste(app, Some(text), None), Event::Key { key, physical_key, pressed: true, modifiers, repeat } if key_event(app, *key, *physical_key, *modifiers, *repeat, now, ctx) => { @@ -133,23 +135,59 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, paste_release: bool } } -/// Whether this frame releases a Cmd/Ctrl+V whose press never arrived and that brought no paste -/// event: egui-winit swallows the press and sends a paste event only when the clipboard holds -/// text, so an image on the clipboard leaves just this release. Call at the start of every frame: -/// it reads the raw events, which keys consumed by panels or the presenter still carry. -pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> bool { +/// The command Cmd/Ctrl+V runs with `m` held too: `edit.paste`, `edit.pasteOver` (Shift), +/// `edit.pasteProperties` (Alt), or `None` for a chord nothing is bound to. The keymap decides, +/// whether the key press arrived (browser) or only a paste event and the release (desktop). +fn v_binding(m: Modifiers) -> Option<&'static str> { + lookup(keymap::Context::Canvas, Key::V, Some(Key::V), Modifiers { command: true, ..m }).map(|b| b.id) +} + +/// What this frame's Cmd/Ctrl+V chords ask for when the desktop shell swallowed their press +/// ([`swallowed_paste`]). +#[derive(Clone, Copy, Default)] +pub struct Swallowed { + /// The command to run ([`v_binding`]). + pub run: Option<&'static str>, + /// This frame's paste event belongs to another command or an unbound chord: its text isn't + /// pasted. + pub drop_text: bool, +} + +/// egui-winit swallows every Cmd/Ctrl+V press, Shift or Alt held or not, and sends a paste event +/// only when the clipboard holds text. So a chord brings a paste event (text) or just its release +/// (an image, or nothing). The browser sends the press as well, which runs the command itself. +/// Call at the start of every frame: it reads the raw events, which keys consumed by panels or the +/// presenter still carry. +pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> Swallowed { let ui = &mut app.ui; - let mut paste = false; + let mut out = Swallowed::default(); ctx.input(|i| { for ev in &i.raw.events { + if let Event::Key { modifiers, .. } = ev { + ui.held = *modifiers; + } match ev { + Event::ModifiersChanged(m) => ui.held = *m, + // Without Cmd/Ctrl held it's another paste key (Shift+Insert, a Paste key). + Event::Paste(_) if ui.held.command => { + let id = v_binding(ui.held); + if id != Some("edit.paste") { + out.drop_text = true; + // Held down, the chord repeats its paste event: run once, as keys do. + if !ui.v_pressed && !ui.paste_event { + out.run = out.run.or(id); + } + } + ui.paste_event = true; + } Event::Paste(_) => ui.paste_event = true, Event::Key { key: Key::V, pressed: true, .. } => ui.v_pressed = true, Event::Key { key: Key::V, pressed: false, modifiers, .. } => { - // Shift or Alt with it is another command (paste over, paste properties). A V - // held while the window was away comes back as a release alone: without Cmd - // held, that's no paste. - paste = !ui.v_pressed && !ui.paste_event && !modifiers.shift && !modifiers.alt && (modifiers.command || !ui.refocused); + // Cmd was down when V went down, maybe let go first. A V held while the window + // was away comes back as a release alone: without Cmd held, that's no paste. + if !ui.v_pressed && !ui.paste_event && (modifiers.command || !ui.refocused) { + out.run = out.run.or(v_binding(*modifiers)); + } ui.v_pressed = false; ui.paste_event = false; ui.refocused = false; @@ -160,13 +198,14 @@ pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> bool { Event::WindowFocused(false) => { ui.v_pressed = false; ui.paste_event = false; + ui.held = Modifiers::NONE; } Event::WindowFocused(true) => ui.refocused = true, _ => {} } } }); - paste + out } /// Run the command a shortcut is bound to, with the keyboard's own twists (Escape steps out diff --git a/crates/ui-egui/src/web_clipboard_tests.rs b/crates/ui-egui/src/web_clipboard_tests.rs index a7787120..26389ff4 100644 --- a/crates/ui-egui/src/web_clipboard_tests.rs +++ b/crates/ui-egui/src/web_clipboard_tests.rs @@ -86,6 +86,26 @@ impl Page { self.frame(vec![Self::key(Key::V, false)]); } + /// Cmd+V with `m` added, as the browser delivers it: the key press, then the paste event. + fn chord(&mut self, m: Modifiers) { + let m = m | Modifiers::MAC_CMD | Modifiers::COMMAND; + let v = |pressed| Event::Key { key: Key::V, physical_key: Some(Key::V), pressed, repeat: false, modifiers: m }; + let text = self.clipboard.clone(); + self.frame(vec![Event::ModifiersChanged(m), v(true), Event::Paste(text)]); + self.frame(vec![v(false)]); + self.frame(vec![Event::ModifiersChanged(Modifiers::NONE)]); + } + + /// Copy the selected rectangle A (made red) and its properties, then select a new rectangle B. + fn copy_a_select_b(&mut self) -> figcraft_doc::NodeId { + self.app.session.execute("fill.set", &json!({"color": "#e53935"})).unwrap(); + self.copy(); + self.app.session.execute("edit.copyProperties", &json!({})).unwrap(); + let b = self.app.session.execute("shape.rectangle", &json!({"x": 400, "y": 250, "width": 100, "height": 50})).unwrap(); + self.app.session.execute("select.set", &json!({"ids": [b["id"]]})).unwrap(); + figcraft_doc::NodeId(b["id"].as_u64().unwrap()) + } + fn count(&self, kind: fn(&NodeKind) -> bool) -> usize { self.app.session.doc().unwrap().nodes().filter(|n| kind(&n.kind)).count() } @@ -121,6 +141,52 @@ fn text_copied_elsewhere_after_our_copy_still_pastes_as_text() { assert_eq!(p.count(text), 1, "newer text from elsewhere becomes a text layer"); } +#[test] +fn shift_cmd_v_pastes_over_once_and_no_text() { + for foreign in [false, true] { + let mut p = Page::new(); + let b = p.copy_a_select_b(); + if foreign { + p.clipboard = "hello from another app".into(); + } + p.chord(Modifiers::SHIFT); + assert_eq!(p.count(rect), 3, "foreign text {foreign}: one copy pasted over B"); + assert_eq!(p.count(text), 0, "foreign text {foreign}: the paste event pasted nothing"); + let doc = p.app.session.doc().unwrap(); + let over = doc.nodes().filter(|n| n.id != b).filter_map(|n| doc.world_bounds(n.id)).any(|r| r.x0 == 400.0 && r.y0 == 250.0); + assert!(over, "foreign text {foreign}: the copy sits at B's top-left"); + } +} + +#[test] +fn alt_cmd_v_pastes_properties_once_and_no_text() { + for foreign in [false, true] { + let mut p = Page::new(); + let b = p.copy_a_select_b(); + if foreign { + p.clipboard = "hello from another app".into(); + } + p.chord(Modifiers::ALT); + assert_eq!(p.count(rect), 2, "foreign text {foreign}: no layer pasted"); + assert_eq!(p.count(text), 0, "foreign text {foreign}: the paste event pasted nothing"); + let fill = figcraft_engine::inspect::node_json(p.app.session.doc().unwrap(), b, 0)["fills"][0]["color"].clone(); + assert_eq!(fill, "#E53935", "foreign text {foreign}: B took A's fill"); + } +} + +#[test] +fn a_chord_tapped_within_one_frame_runs_once() { + let mut p = Page::new(); + p.copy_a_select_b(); + let m = Modifiers::SHIFT | Modifiers::MAC_CMD | Modifiers::COMMAND; + let v = |pressed| Event::Key { key: Key::V, physical_key: Some(Key::V), pressed, repeat: false, modifiers: m }; + let clip = p.clipboard.clone(); + p.frame(vec![Event::ModifiersChanged(m), v(true), Event::Paste(clip), v(false), Event::ModifiersChanged(Modifiers::NONE)]); + p.frame(vec![]); + assert_eq!(p.count(rect), 3, "one copy pasted over B"); + assert_eq!(p.count(text), 0); +} + #[test] fn copy_as_png_puts_an_image_on_the_clipboard() { let mut p = Page::new(); From 18a7a063fe29627f7d02cbf8aab6ba36fc63f45a Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Sat, 17 Oct 2026 09:00:56 +0700 Subject: [PATCH 057/118] fix(ui): paste chords read modifiers from modifier changes only (#716) Every shell reports a modifier change before the key it goes with, so key events needn't update the held modifiers too. The old test that pressed Shift+Cmd+V and Alt+Cmd+V expecting "nothing" now covers plain V only: those chords run their commands, covered by their own tests. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/lib.rs | 3 ++- crates/ui-egui/src/paste_image_tests.rs | 4 +--- crates/ui-egui/src/shortcuts.rs | 3 --- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a64ef6a8..e32e8e6e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -502,7 +502,8 @@ pub struct UiState { pub paste_event: bool, /// The window got the focus back since the last V release. pub refocused: bool, - /// The modifiers held, as of the last raw event that told: a paste event doesn't carry them. + /// The modifiers held, as of the last modifier change in the raw events: a paste event doesn't + /// carry them. pub held: egui::Modifiers, /// When (UI clock, seconds) open documents next get an autosave checkpoint. pub next_autosave: f64, diff --git a/crates/ui-egui/src/paste_image_tests.rs b/crates/ui-egui/src/paste_image_tests.rs index 23cec8ea..48bd142e 100644 --- a/crates/ui-egui/src/paste_image_tests.rs +++ b/crates/ui-egui/src/paste_image_tests.rs @@ -231,13 +231,11 @@ fn the_browser_sequence_pastes_once() { } #[test] -fn v_alone_and_other_paste_commands_paste_nothing() { +fn v_alone_pastes_nothing() { let mut w = Win::new(); w.copy_image_elsewhere(); w.frame(vec![key(Key::V, true, Modifiers::NONE)]); w.frame(vec![key(Key::V, false, Modifiers::NONE)]); - w.cmd_v(CMD.plus(Modifiers::SHIFT)); - w.cmd_v(CMD.plus(Modifiers::ALT)); assert_eq!(w.named("Pasted image"), 0); } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 57295c22..0a291a7c 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -163,9 +163,6 @@ pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> Swallowed { let mut out = Swallowed::default(); ctx.input(|i| { for ev in &i.raw.events { - if let Event::Key { modifiers, .. } = ev { - ui.held = *modifiers; - } match ev { Event::ModifiersChanged(m) => ui.held = *m, // Without Cmd/Ctrl held it's another paste key (Shift+Insert, a Paste key). From df674d0d53749572250809226ac607cd523e4034 Mon Sep 17 00:00:00 2001 From: justinjohn0306 Date: Sat, 17 Oct 2026 08:17:28 +0530 Subject: [PATCH 058/118] #710: Resources and Shift+I open the Assets panel from a collapsed sidebar or the version history Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 8 +++- crates/ui-egui/src/resources_tests.rs | 67 +++++++++++++++++++++++++++ crates/ui-egui/src/toolbar.rs | 3 +- 3 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/src/resources_tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20a8a77c..f2f74c1a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -162,6 +162,8 @@ mod proto_reorder_tests; #[cfg(test)] mod proto_responsive_tests; #[cfg(test)] +mod resources_tests; +#[cfg(test)] mod select_all_tests; #[cfg(test)] mod shortcut_keys_tests; @@ -810,7 +812,11 @@ impl FigApp { } "focus.region" => self.ui.nav.step(r.data.get("step").and_then(Value::as_i64).unwrap_or(1).clamp(-4, 4) as i32), "panel.layers" => self.ui.left_tab = LeftTab::File, - "panel.assets" => self.ui.left_tab = LeftTab::Assets, + "panel.assets" => { + self.ui.left_tab = LeftTab::Assets; + self.ui.left_collapsed = false; + self.ui.versions = Default::default(); + } "panel.versionHistory" => versions::open(self), "view.devCompare" => { self.ui.dev.compare = r.data.get("id").and_then(figcraft_engine::params::id_of).map(dev_compare::CompareView::new); diff --git a/crates/ui-egui/src/resources_tests.rs b/crates/ui-egui/src/resources_tests.rs new file mode 100644 index 00000000..42869098 --- /dev/null +++ b/crates/ui-egui/src/resources_tests.rs @@ -0,0 +1,67 @@ +//! The toolbar's Resources button and Shift+I open the Assets panel (#710), from a collapsed +//! sidebar and from the version history too. + +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers, Pos2, pos2}; +use figcraft_engine::Session; + +use crate::LeftTab; +use crate::layers_drag_tests::Ui; + +fn click(u: &mut Ui, p: Pos2) { + u.button(p, true); + u.button(p, false); + u.frame(vec![]); +} + +fn shift_i(u: &mut Ui) { + let modifiers = Modifiers::SHIFT; + u.frame(vec![ + Event::Key { key: Key::I, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key: Key::I, physical_key: None, pressed: false, repeat: false, modifiers }, + ]); + u.frame(vec![]); +} + +/// The middle of the toolbar's Resources button, found in the AccessKit tree. +fn resources_button(u: &mut Ui) -> Pos2 { + u.ctx.enable_accesskit(); + u.frame(vec![]); + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::Button && n.label() == Some("Resources")).expect("Resources"); + let b = n.bounds().expect("bounds"); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +fn assets_shown(u: &Ui) -> bool { + u.app.ui.left_tab == LeftTab::Assets && !u.app.ui.left_collapsed && !u.app.ui.versions.open +} + +/// The three states the sidebar can be in when Resources is asked for. +fn starts() -> Vec<(&'static str, Ui)> { + let shown = Ui::new(Session::new()); + let mut collapsed = Ui::new(Session::new()); + collapsed.app.ui.left_collapsed = true; + let mut versions = Ui::new(Session::new()); + crate::versions::open(&mut versions.app); + vec![("layers", shown), ("collapsed", collapsed), ("version history", versions)] +} + +#[test] +fn the_resources_button_opens_the_assets_panel() { + for (name, mut u) in starts() { + let at = resources_button(&mut u); + click(&mut u, at); + assert!(assets_shown(&u), "{name}: the Assets panel is open"); + } +} + +#[test] +fn shift_i_opens_the_assets_panel() { + for (name, mut u) in starts() { + u.frame(vec![]); + shift_i(&mut u); + assert!(assets_shown(&u), "{name}: the Assets panel is open"); + } +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index a921cc29..c2f947f8 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -137,8 +137,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { if id == "actions" { app.ui.palette = Some(String::new()); } else { - app.ui.left_tab = crate::LeftTab::Assets; - app.ui.left_collapsed = false; + app.run("view.assets", json!({})); } } x += cell + 4.0; From 40c744da1092fce41803da7f7ef884cac8d68aab Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 17 Oct 2026 13:03:49 +0700 Subject: [PATCH 059/118] M1: convert frames to sections and sections to frames (#719) (#721) Frames on the page or in a section convert to sections (object.convertToSection) and sections back to frames (object.convertToFrame), in place, one undo step each, from the canvas context menu; nested frames, instances, main components and sections holding sections are refused. --- ROADMAP.md | 4 +- crates/engine/src/cmd/structure.rs | 62 ++++++++++++++++++-- crates/engine/src/convert_container_tests.rs | 52 +++++++++++++++- crates/ui-egui/locales/en.toml | 2 + crates/ui-egui/locales/es.toml | 3 + crates/ui-egui/src/menus.rs | 21 +++++++ docs/mcp.md | 2 + docs/target-app-parity.md | 3 +- 8 files changed, 140 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c3c12085..bbde8bc5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. - **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. @@ -313,6 +314,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | | 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index bed70d3d..5643c058 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -26,10 +26,17 @@ pub fn specs() -> Vec { spec!( "object.convertToFrame", "Convert to frame", - "{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping. Frames are left alone; instances, main components and layers inside instances are refused)", + "{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping; sections become frames that keep their fill and don't clip. Frames are left alone; instances, main components, layers inside instances and sections holding sections are refused)", true, |s, p| convert(s, p, "object.convertToFrame", true) ), + spec!( + "object.convertToSection", + "Convert to section", + "{ids?} (frames on the page or in a section become sections in place, keeping their fill and layers; what only a frame has goes: auto layout, clipping, layout grids, guides and scrolling. Sections are left alone; nested frames, instances and main components are refused)", + true, + convert_to_section + ), spec!( "object.wrapInSection", "Wrap in new section", @@ -199,6 +206,13 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res let n = doc.get(i)?; let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name))); match (&n.kind, to_frame) { + // A frame can't hold sections. + (NodeKind::Section { .. }, true) + if n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Section { .. }))) => + { + return refuse("holds sections, which can't go inside a frame: convert those first"); + } + (NodeKind::Section { .. }, true) => todo.push(i), (NodeKind::Instance { .. }, _) => return refuse("is an instance: its layers come from its main component (detach it first)"), (NodeKind::Component { .. }, _) => return refuse("is a main component, which stays a frame"), (NodeKind::Frame | NodeKind::Group, _) if crate::components::owning_instance(doc, i).is_some() => { @@ -212,12 +226,15 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res } for i in &todo { if to_frame { - // The group's box as it is; nothing painted or clipped, so the look stays. + // The box as it is and nothing clipped, so the look stays: a group has nothing painted, a + // section keeps its fill. let n = doc.get_mut(*i)?; + if matches!(n.kind, NodeKind::Group) { + n.fills.clear(); + n.strokes.clear(); + } n.kind = NodeKind::Frame; n.clip = false; - n.fills.clear(); - n.strokes.clear(); continue; } let n = doc.get_mut(*i)?; @@ -242,6 +259,43 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res Ok(json!({"ids": todo})) } +/// Frames on the page or in a section become sections in place (sections only sit there). +fn convert_to_section(s: &mut Session, p: &Value) -> Result { + let cmd = "object.convertToSection"; + let ids = req_targets(cmd, s, p)?; + let doc = s.doc_mut()?; + let mut todo = Vec::new(); + for i in ids { + let n = doc.get(i)?; + let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name))); + let on_board = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. })); + match &n.kind { + NodeKind::Section { .. } => {} + NodeKind::Instance { .. } => return refuse("is an instance: its layers come from its main component (detach it first)"), + NodeKind::Component { .. } | NodeKind::ComponentSet { .. } => return refuse("is a main component, which stays a frame"), + NodeKind::Frame if !on_board => return refuse("is inside a frame or group: only frames on the page or in a section become sections"), + NodeKind::Frame => todo.push(i), + _ => return refuse("is not a frame"), + } + } + for i in &todo { + let n = doc.get_mut(*i)?; + n.kind = NodeKind::Section { contents_hidden: false }; + (n.layout, n.clip, n.overflow) = (None, false, proto::Overflow::None); + n.grids.clear(); + n.guides.clear(); + n.styles.grid = None; + n.bound.retain(|k, _| !k.starts_with("padding") && !matches!(k.as_str(), "gap" | "counterAxisSpacing")); + let kids = n.children.clone(); + for k in kids { + // Out of the flow there is nothing to fill or to be absolute against. + doc.get_mut(k)?.child_layout.absolute = false; + } + figcraft_layout::coherent_sizing(doc, *i); + } + Ok(json!({"ids": todo})) +} + fn ungroup(s: &mut Session, p: &Value) -> Result { let ids = req_targets("object.ungroup", s, p)?; let doc = s.doc_mut()?; diff --git a/crates/engine/src/convert_container_tests.rs b/crates/engine/src/convert_container_tests.rs index 7c25a620..620388d2 100644 --- a/crates/engine/src/convert_container_tests.rs +++ b/crates/engine/src/convert_container_tests.rs @@ -1,5 +1,5 @@ -//! Frames and groups converted into each other in place: the layers stay where they are on the -//! page, what only a frame has goes with it, what can't convert is refused, one undo step. +//! Frames, groups and sections converted into each other in place: the layers stay where they are on +//! the page, what only a frame has goes with it, what can't convert is refused, one undo step. use serde_json::{Value, json}; @@ -136,7 +136,7 @@ fn what_cannot_convert_is_refused_and_nothing_changes() { #[test] fn junk_parameters_are_refused_not_fatal() { let junk = [json!(null), json!(true), json!(-1), json!(1e308), json!("x"), json!([[1]]), json!({"a": 1}), json!([0, 999_999, "7"])]; - for cmd in ["object.convertToGroup", "object.convertToFrame"] { + for cmd in ["object.convertToGroup", "object.convertToFrame", "object.convertToSection"] { for j in &junk { let mut s = Session::new(); let f = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50}))); @@ -148,3 +148,49 @@ fn junk_parameters_are_refused_not_fatal() { } } } + +#[test] +fn a_frame_on_the_page_becomes_a_section_and_back() { + let mut s = Session::new(); + let f = id(ex(&mut s, "frame.create", json!({"x": 100, "y": 50, "width": 300, "height": 200, "name": "Flow", "props": {"fill": "#FFEEDD"}}))); + let (a, b) = (rect(&mut s, f, 20.0, 30.0), rect(&mut s, f, 400.0, 90.0)); + ex(&mut s, "layout.add", json!({"ids": [f], "direction": "horizontal"})); + ex(&mut s, "node.set", json!({"id": f, "props": {"width": 300, "height": 200}})); + let before = [page_box(&s, a), page_box(&s, b), page_box(&s, f)]; + assert_eq!(ex(&mut s, "object.convertToSection", json!({"ids": [f]}))["ids"], json!([f])); + let n = s.doc().unwrap().node(f).unwrap().clone(); + assert!(matches!(n.kind, NodeKind::Section { contents_hidden: false })); + assert_eq!((n.name.as_str(), n.children.clone(), n.fills.len()), ("Flow", vec![a, b], 1), "same name, layers and fill"); + assert!(n.layout.is_none() && !n.clip, "no auto layout or clipping on a section"); + assert_eq!([page_box(&s, a), page_box(&s, b), page_box(&s, f)], before, "nothing moves"); + // And back: a frame that keeps the fill and doesn't clip, so the look stays. + assert_eq!(ex(&mut s, "object.convertToFrame", json!({"ids": [f]}))["ids"], json!([f])); + let n = s.doc().unwrap().node(f).unwrap().clone(); + assert!(matches!(n.kind, NodeKind::Frame) && n.fills.len() == 1 && !n.clip); + assert_eq!([page_box(&s, a), page_box(&s, b), page_box(&s, f)], before); + // Each conversion is one undo step. + ex(&mut s, "edit.undo", json!({})); + assert!(matches!(kind(&s, f), NodeKind::Section { .. })); + ex(&mut s, "edit.undo", json!({})); + assert!(matches!(kind(&s, f), NodeKind::Frame)); + assert!(s.doc().unwrap().node(f).unwrap().layout.is_some(), "the frame's auto layout is back"); +} + +#[test] +fn only_frames_on_the_page_or_in_a_section_become_sections() { + let mut s = Session::new(); + let outer = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 300}))); + let inner = id(ex(&mut s, "frame.create", json!({"parent": outer, "x": 10, "y": 10, "width": 100, "height": 100}))); + let err = s.execute("object.convertToSection", &json!({"ids": [inner]})).unwrap_err().to_string(); + assert!(err.contains("inside a frame"), "{err}"); + let r = rect(&mut s, outer, 150.0, 150.0); + assert!(s.execute("object.convertToSection", &json!({"ids": [r]})).is_err(), "a rectangle isn't a frame"); + // A frame inside a section converts; a section holding a section can't become a frame. + let sec = id(ex(&mut s, "node.create", json!({"type": "section", "x": 500, "y": 0, "width": 400, "height": 400}))); + let in_sec = id(ex(&mut s, "frame.create", json!({"parent": sec, "x": 20, "y": 20, "width": 100, "height": 100}))); + ex(&mut s, "object.convertToSection", json!({"ids": [in_sec]})); + assert!(matches!(kind(&s, in_sec), NodeKind::Section { .. })); + let err = s.execute("object.convertToFrame", &json!({"ids": [sec]})).unwrap_err().to_string(); + assert!(err.contains("holds sections"), "{err}"); + assert!(matches!(kind(&s, sec), NodeKind::Section { .. }), "nothing changed"); +} diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 3c127fc6..c9b6df62 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -201,6 +201,8 @@ name = "English" "Code or invite link" = "Code or invite link" "Color profile: {name}" = "Color profile: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Colour and text styles, Light and Dark variables, buttons, inputs and cards" +"Convert to frame" = "Convert to frame" +"Convert to section" = "Convert to section" "CSS custom properties…" = "CSS custom properties…" "Can edit" = "Can edit" "Can't read this library: {e}" = "Can't read this library: {e}" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 901283f1..ff3e5a79 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -302,6 +302,8 @@ status = "draft — needs native review" "Copied as SVG" = "Copiado como SVG" "Copied as text" = "Copiado como texto" "Copied {value}" = "Copiado: {value}" +"Convert to frame" = "Convertir en marco" +"Convert to section" = "Convertir en sección" "Copy" = "Copiar" "Copy as" = "Copiar como" "Copy as CSS" = "Copiar como CSS" @@ -1839,6 +1841,7 @@ status = "draft — needs native review" "object.batchRename" = "Cambiar nombre en lote" "object.convertToFrame" = "Convertir en marco" "object.convertToGroup" = "Convertir en grupo" +"object.convertToSection" = "Convertir en sección" "object.flipH" = "Voltear horizontalmente" "object.flipV" = "Voltear verticalmente" "object.frameSelection" = "Enmarcar selección" diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b9d3299a..498dc8de 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -467,6 +467,22 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { .collect(); (!secs.is_empty()).then(|| if secs.iter().all(|h| *h) { tr("Show section contents") } else { tr("Hide section contents") }) }); + // Frames on the page (or in a section) convert to sections, and sections to frames. + let convert = app.session.doc().ok().and_then(|doc| { + let nodes: Vec<_> = ids.iter().filter_map(|i| doc.node(*i)).collect(); + let on_board = |n: &figcraft_doc::Node| { + n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, figcraft_doc::NodeKind::Section { .. })) + }; + if nodes.is_empty() { + None + } else if nodes.iter().all(|n| matches!(n.kind, figcraft_doc::NodeKind::Section { .. })) { + Some((tr("Convert to frame"), "object.convertToFrame")) + } else if nodes.iter().all(|n| matches!(n.kind, figcraft_doc::NodeKind::Frame) && on_board(n)) { + Some((tr("Convert to section"), "object.convertToSection")) + } else { + None + } + }); let mut entries: Vec<(&str, &str)> = if has_sel { vec![ (tr("Copy"), "edit.copy"), @@ -519,6 +535,11 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { { entries.insert(at + 1, (label, "section.toggleContents")); } + if let Some(entry) = convert + && let Some(at) = entries.iter().position(|e| e.1 == "object.wrapInSection") + { + entries.insert(at + 1, entry); + } // A right-clicked ruler guide (selected, with no layers) can be removed. if !has_sel && app.session.ds().is_ok_and(|d| d.selected_guide().is_some()) { entries.splice(0..0, [(tr("Remove guide"), "guide.remove"), ("-", "")]); diff --git a/docs/mcp.md b/docs/mcp.md index 4efee72a..64228ea7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -114,6 +114,8 @@ Or in a project's `.mcp.json`: columns (vertical layouts wrap top to bottom into new columns). - `object.convertToGroup` / `object.convertToFrame` turn a frame into a group or back in place (layers stay put; a group has no auto layout, fill, stroke, corner radius, clipping or grids). + - `object.convertToSection` turns a frame on the page or in a section into a section in place, and + `object.convertToFrame` turns a section back into a frame (keeping its fill, not clipping). - `contentAlign` (wrapping layouts): `auto` (default) packs the lines `crossGap` apart at the start, centre or end `crossAlign` names (lines of nothing but fill children stretch instead); `spaceBetween` puts the free space between the lines (one line, or no free space: like `auto`). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e15ddaed..7c89f239 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -628,6 +628,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | | 2026-10-13 | minor | Vector edit mode regions: the filled area under the pointer is hatched (clear of points and segments, holes left out, spacing by octave folding fitted to measurements at several zoom levels, fixed to the shape); click, Shift-click and drag select and move areas with their points (drawn unselected); a click on an empty spot clears them and stays in edit mode (see [ui-parity](ui-parity.md)) | | 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. | From fa4821bcea03a2a0eae40609c9ffcbda3383f132 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:27:11 +0700 Subject: [PATCH 060/118] #627: settle bound paints once per open, not also in the native loader DocState::new already settles every opened document (native files, branches and live joins included); the .fig/REST path keeps its settlement in link_instances for file.import into an open document. --- crates/engine/src/cmd/file.rs | 1 - crates/engine/src/variable_tests.rs | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f9175b9c..28124b7c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -520,7 +520,6 @@ fn load_with( if loaded.doc.name.is_empty() { loaded.doc.name = file_stem(name_hint); } - super::styles::apply_paint_bindings_on_load(&mut loaded.doc); Ok((loaded, false)) } } diff --git a/crates/engine/src/variable_tests.rs b/crates/engine/src/variable_tests.rs index f5808b2c..dcdd04e3 100644 --- a/crates/engine/src/variable_tests.rs +++ b/crates/engine/src/variable_tests.rs @@ -53,7 +53,10 @@ fn initial_color_bindings_settle_in_constructor_and_loaders() { let native = figcraft_format::save_bytes(&stale, None).unwrap(); let (loaded, _) = crate::cmd::file::load_bytes(&native, "bindings.figcraft").unwrap(); - let loaded_id = assert_dark_bound_color(&loaded, name_transform); + // A native file settles when it opens as a document, not in the loader. + let opened = Session::with_document(loaded); + let loaded = opened.doc().unwrap(); + let loaded_id = assert_dark_bound_color(loaded, name_transform); assert_eq!(loaded_id, id); assert_eq!(loaded.node(id).unwrap().source, stale.first_page()); assert!(loaded.node(id).unwrap().overrides.contains("sentinel")); From 6e15e30790acc1b8d062b31fb4ce9168eba09207 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:27:12 +0700 Subject: [PATCH 061/118] #627: remove the dead network::decode shim --- crates/format/src/fig/network.rs | 9 ++------- crates/format/src/fig/tail_tests.rs | 12 ++++++------ 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/crates/format/src/fig/network.rs b/crates/format/src/fig/network.rs index d251dff1..92ad4898 100644 --- a/crates/format/src/fig/network.rs +++ b/crates/format/src/fig/network.rs @@ -64,14 +64,9 @@ impl Reader<'_> { } } -/// The network in blob `b`; `None` when it's truncated, out of range or not finite. -#[allow(dead_code, reason = "for readers that don't need the count of styled segments")] -pub(super) fn decode(b: &[u8]) -> Option { - decode_styled(b).map(|(net, _)| net) -} - /// The network in blob `b` and how many of its segments have a style id of their own (per-segment -/// stroke overrides, which the document model can't hold); `None` as for `decode`. +/// stroke overrides, which the document model can't hold); `None` when it's truncated, out of +/// range or not finite. pub(super) fn decode_styled(b: &[u8]) -> Option<(Network, usize)> { let mut r = Reader { b, pos: 0 }; let (nv, ns, nr) = (r.u32()? as usize, r.u32()? as usize, r.u32()? as usize); diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 5d34f23b..19618044 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -355,9 +355,9 @@ fn unreadable_conditions_skip_the_conditional() { #[test] fn damaged_network_blobs_never_panic() { let good = square_blob(); - assert!(network::decode(&good).is_some()); + assert!(network::decode_styled(&good).is_some()); for cut in 0..good.len() { - assert!(network::decode(&good[..cut]).is_none(), "{cut}"); + assert!(network::decode_styled(&good[..cut]).is_none(), "{cut}"); } let mut r = Lcg(5); for _ in 0..5000 { @@ -366,7 +366,7 @@ fn damaged_network_blobs_never_panic() { let i = (r.next() as usize) % m.len(); m[i] ^= 1 << (r.next() % 8); } - if let Some(net) = network::decode(&m) { + if let Some((net, _)) = network::decode_styled(&m) { assert!(net.segments.iter().all(|s| s.a < net.vertices.len() && s.b < net.vertices.len())); assert!(net.regions.iter().flat_map(|r| &r.loops).flatten().all(|i| *i < net.segments.len())); assert!(net.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite())); @@ -375,13 +375,13 @@ fn damaged_network_blobs_never_panic() { // Counts larger than the blob, and indices out of range. let mut huge = vec![0xff; 12]; huge.extend_from_slice(&[0; 64]); - assert!(network::decode(&huge).is_none()); + assert!(network::decode_styled(&huge).is_none()); let mut bad = arrow_blob(); bad[12 + 24 + 4..12 + 24 + 8].copy_from_slice(&7u32.to_le_bytes()); - assert!(network::decode(&bad).is_none()); + assert!(network::decode_styled(&bad).is_none()); for _ in 0..2000 { let junk: Vec = (0..(r.next() % 120)).map(|_| r.next() as u8).collect(); - let _ = network::decode(&junk); + let _ = network::decode_styled(&junk); } } From b4d58a362e3350fd7416d8dc528d70dc6e372007 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:27:13 +0700 Subject: [PATCH 062/118] #627: leave the progress docs to the maintainer --- docs/file-format-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index e56eda19..d8e51711 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -17,7 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, per-segment stroke styles, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout, constraints, grids, components, sets, instances with overrides, component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | From 51e02a6b1a31b75e7b686a410b2f40e41d3f6fed Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:27:28 +0700 Subject: [PATCH 063/118] #695: leave progress docs to the maintainer's docs PR --- docs/file-format-parity.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6f0cce80..d8e51711 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (`.fig` numbered list start numbers (#686); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -17,7 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs and list types (numbered lists keep the number they start at), vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: nested list levels and where a new list begins right after another numbered one (`isFirstLineOfList`), connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout, constraints, grids, components, sets, instances with overrides, component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | @@ -88,8 +88,7 @@ each. layers sit in it (fill, absolute, min/max sizes); layout grids; export settings; text style (font, with styles under their standard names such as "Semi Bold"; size; line height, a share of the font size as a raw multiplier; letter spacing, case, decoration, links), alignment, auto resize, - truncation, paragraph spacing and indent, list types (with the number a numbered list starts - at, on every line of the list; not yet checked in other software) and styled runs with their fills (indexed + truncation, paragraph spacing and indent, list types and styled runs with their fills (indexed by UTF-16 unit); components (description, boolean, text and instance swap properties and the layers bound to them), component sets, instances (their main, property values, what the instance itself changes from its main, such as its size, name and paints, as an override addressed by the @@ -163,7 +162,6 @@ each. | Date | Change | Summary | |---|---|---| -| 2026-10-16 | minor | `.fig` numbered lists keep the number they start at (#686): a list Figma shows as starting at 2 imported as 1. The import reads each line's start offset, a numbered paragraph keeps it (older files open unchanged), numbering counts on from it, `get_node` reports it (`listStartOffsets`), and the export writes it on every line of the list. Nested list levels and a new list right after another numbered one are still not read from `.fig` files. Numbers unchanged | | 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. | | 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Opened `.fig` and file JSON documents link nested instances of variants (and any nested instance under a name of its own) to their main's layers; they stayed unlinked, so a second export lost what they changed. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | | 2026-10-12 | minor | `.fig` export made to open elsewhere (gap 1): the document and its pages say they are visible and opaque (left out, nothing was drawn), the schema in the file uses the format's own names, kinds, field ids and enum values, rectangles are rounded rectangles with four radii, frames and shapes carry their fill outline and plain strokes their stroke outline, text carries its layout settings, font styles use their standard names, line heights that are a share of the font size are raw multipliers, masks keep their type (read back too); the import reads per-side stroke weights whose zero sides are left out. Written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers unchanged | From 7dcd7fd05bfa820bf6adb0fe4e607f1e9ee5c89f Mon Sep 17 00:00:00 2001 From: Andrii Volkov <60794004+volkovskey@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:27:42 +0300 Subject: [PATCH 064/118] fix(format): one kiwi value per compressed canvas byte One value per two bytes of the compressed canvas was too tight for a public design system: a 39.5 MB canvas holding 29.7 million values got a budget of 19.7 million (reported in #701). Real files hold 0.37 to 0.75 values per compressed byte; the budget is now one per byte, at least the old 20 million and at most 128 million. A small file still gets main's 20 million, however far it unpacks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki --- crates/format/src/fig/kiwi.rs | 10 +++++----- crates/format/src/fig/tests.rs | 7 +++++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs index 46a9daac..6496062b 100644 --- a/crates/format/src/fig/kiwi.rs +++ b/crates/format/src/fig/kiwi.rs @@ -11,11 +11,11 @@ use std::fmt; /// Deepest struct/message nesting decoded. const MAX_DEPTH: usize = 64; /// Most values decoded from one message (every scalar, array element and field counts): at least -/// [`MIN_VALUES`], and one per [`PACKED_BYTES_PER_VALUE`] bytes of the file's compressed canvas, -/// so a large real file decodes while a small one can't expand into a huge tree, however far its -/// chunks inflate. +/// [`MIN_VALUES`], and one per byte of the file's compressed canvas up to [`MAX_VALUES`], so a large +/// real file decodes while a small one can't expand into a huge tree, however far its chunks +/// inflate. Real files hold 0.37 to 0.75 values per compressed byte. const MIN_VALUES: usize = 20_000_000; -const PACKED_BYTES_PER_VALUE: usize = 2; +const MAX_VALUES: usize = 128 << 20; /// Most definitions in a schema, and fields in one definition. const MAX_DEFS: usize = 10_000; const MAX_FIELDS: usize = 10_000; @@ -302,7 +302,7 @@ impl Schema { /// Most values decoded from a file whose compressed canvas is `packed` bytes. pub(crate) fn value_budget(packed: usize) -> usize { - MIN_VALUES.max(packed / PACKED_BYTES_PER_VALUE) + packed.clamp(MIN_VALUES, MAX_VALUES) } struct Decoder<'s, 'b> { diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 574f860c..c2fae249 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -629,8 +629,11 @@ fn large_files_get_a_larger_value_budget() { // A real 400 000-layer file: an 83.5 MB canvas whose message unpacks to 334 MB and holds // 30.5 million values was refused as "too many values". assert!(kiwi::value_budget(83_491_232) > 30_514_899); - // Still bounded by the largest canvas read. - assert!(kiwi::value_budget(MAX_CANVAS as usize) <= 128 << 20); + // A public design system packs more densely: a 39.5 MB canvas holding 29.7 million values, + // which one value per two compressed bytes refused. + assert!(kiwi::value_budget(39_500_000) > 29_703_730); + // Never more than the ceiling, however large the canvas. + assert_eq!(kiwi::value_budget(MAX_CANVAS as usize), 128 << 20); } #[test] From 69806c9edd85d8fac2a76eda32c75bc6ede262d6 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 17 Oct 2026 13:35:00 +0700 Subject: [PATCH 065/118] Plugins: refuse file and network access through path params and collab (#723) Plugins can no longer reach the disk or network through path parameters (variable.export/codeConnect.export/component.playground writes, codeConnect.import/variable.import reads) or through collab.* and window.*; found and fixed by @lucasmartins-ai, split out of #696. --- crates/engine/src/plugins.rs | 15 +++++++++++++++ crates/engine/src/plugins_tests.rs | 7 +++++++ docs/plugins.md | 6 +++++- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/plugins.rs b/crates/engine/src/plugins.rs index f1c4075e..ce5aa0c6 100644 --- a/crates/engine/src/plugins.rs +++ b/crates/engine/src/plugins.rs @@ -111,6 +111,10 @@ const DENIED: &[&str] = &[ "edit.redo", "edit.repeat", "command.batch", + // Network sessions, online files and their recovery copies on disk. + "collab.", + // Opens files in new windows. + "window.", "view.theme", ]; /// Read-only exceptions to [`DENIED`]. @@ -128,6 +132,17 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option { if command == "image.place" && get(params, "path").is_some() { return Some("plugins can't read files: pass the image as `data`".into()); } + // `path` here is a file on disk (elsewhere it is a vector path or an action's place). + if matches!(command, "variable.export" | "codeConnect.export" | "component.playground") && get(params, "path").is_some() { + return Some("plugins can't write files: leave out `path` to get the result back".into()); + } + if command == "codeConnect.import" && get(params, "path").is_some() { + return Some("plugins can't read files: pass the connections as `json`".into()); + } + // Variables JSON may be given as a path: only objects and JSON text for plugins. + if command == "variable.import" && get(params, "variables").and_then(Value::as_str).is_some_and(|t| !t.trim_start().starts_with('{')) { + return Some("plugins can't read files: pass the variables JSON itself".into()); + } if !m.permissions.allows(command) { return Some(format!("`{command}` isn't in the commands {} asked permission for", m.name)); } diff --git a/crates/engine/src/plugins_tests.rs b/crates/engine/src/plugins_tests.rs index 102d675a..657b6825 100644 --- a/crates/engine/src/plugins_tests.rs +++ b/crates/engine/src/plugins_tests.rs @@ -169,6 +169,13 @@ fn files_dialogs_history_and_plugins_are_off_limits() { ("plugin.remove", json!({"id": "org.test.deny"})), ("edit.undo", json!({})), ("command.batch", json!({"commands": [{"command": "file.saveAs", "params": {"path": target}}]})), + ("variable.export", json!({"format": "css", "path": target})), + ("codeConnect.export", json!({"path": target})), + ("codeConnect.import", json!({"path": "/etc/passwd"})), + ("component.playground", json!({"id": a, "path": target})), + ("variable.import", json!({"variables": "/etc/passwd"})), + ("collab.recoverFile", json!({"path": "copy.figcraft", "file": "x", "session": 1, "client": 1, "seq": 0})), + ("window.new", json!({"path": "C:/Windows/win.ini"})), ("doc.switch", json!({"index": 0})), ("pref.keepTool", json!({})), // Without a name it would open inline renaming in the layers panel: a UI request. diff --git a/docs/plugins.md b/docs/plugins.md index 6f23e6ec..5867bd25 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -237,7 +237,11 @@ Whatever its manifest says, FigCraft refuses these calls (the plugin gets the re it can handle): - files and the app: `file.*`, `app.*`, `pref.*`, `help.*`, `library.add`, `libraries.*`, - `image.place` with a `path` (pass the image as base64 `data` instead), `view.theme*`; + `image.place` with a `path` (pass the image as base64 `data` instead), `variable.export`, + `codeConnect.export` and `component.playground` with a `path` (leave it out to get the result + back), `codeConnect.import` with a `path` and `variable.import` with a file path instead of JSON, + `window.*` (new windows open files), `view.theme*`; +- live sessions and online files: `collab.*`; - other documents and the version history: `doc.*` and `version.*` (except `doc.list` and `version.list`); - the undo history and nesting: `edit.undo`, `edit.redo`, `edit.repeat`, `command.batch` (a run is From 0ef8966844fd5b174b23f0e66de6b4af13cd799f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:40:30 +0700 Subject: [PATCH 066/118] #639: drop the ignored test that needs a fixture outside the repo --- .../format/src/fig/connector_label_tests.rs | 68 +------------------ 1 file changed, 1 insertion(+), 67 deletions(-) diff --git a/crates/format/src/fig/connector_label_tests.rs b/crates/format/src/fig/connector_label_tests.rs index 56cea368..2aa6d893 100644 --- a/crates/format/src/fig/connector_label_tests.rs +++ b/crates/format/src/fig/connector_label_tests.rs @@ -1,4 +1,4 @@ -use super::{Archive, decode_canvas, kiwi::Kv, map}; +use super::{kiwi::Kv, map}; fn obj(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { Kv::Object(fields) @@ -202,10 +202,6 @@ fn synthetic_connector_authored_hidden_background_is_retained() { assert_eq!(background["fills"].as_array().unwrap().len(), 1); } -fn connector<'a>(layers: &'a [serde_json::Value], name: &str) -> &'a serde_json::Value { - layers.iter().find(|layer| layer.get("name").and_then(serde_json::Value::as_str) == Some(name)).expect("connector in fixture") -} - fn field_mut<'a, 's>(value: &'a mut Kv<'s>, key: &str) -> Option<&'a mut Kv<'s>> { let Kv::Object(fields) = value else { return None }; fields.iter_mut().find(|(name, _)| *name == key).map(|(_, value)| value) @@ -244,65 +240,3 @@ fn set_override_field(message: &mut Kv<'_>, field: &str, local_id: u64, key: &'s fields.push((key, value)); } } - -#[test] -#[ignore = "set FIGCRAFT_CONNECTOR_LABEL_FIXTURE to the retained synthetic source-initial.zip"] -fn synthetic_export_keeps_saved_vector_gap_and_composed_label_position() { - let path = std::env::var_os("FIGCRAFT_CONNECTOR_LABEL_FIXTURE").expect("fixture path is required when running this test"); - let bytes = std::fs::read(path).expect("read synthetic connector export"); - let archive = Archive::open(&bytes).expect("open synthetic connector export"); - let (schema, raw_message) = decode_canvas(&archive.canvas).expect("decode synthetic canvas"); - let root = schema.find("Message").expect("Message definition"); - let message = schema.decode_message(root, &raw_message).expect("decode synthetic message"); - let (rest, warnings) = map::to_rest(&message, "connector labels"); - let layers = rest.pointer("/document/children/0/children").and_then(serde_json::Value::as_array).expect("page layers"); - - let plain = connector(layers, "Connector line"); - assert_eq!(plain["type"], "VECTOR", "an unlabelled connector stays one vector"); - assert_eq!(plain["strokeCapStart"], "ROUND"); - assert_eq!(plain["strokeCapEnd"], "LINE_ARROW"); - assert!(plain.get("children").is_none()); - - let alpha = connector(layers, "STRAIGHT alpha"); - let alpha_children = alpha["children"].as_array().expect("snapshot children"); - let alpha_vector = alpha_children.iter().find(|child| child["type"] == "VECTOR").expect("saved connector vector"); - let segments = alpha_vector.pointer("/vectorNetwork/segments").and_then(serde_json::Value::as_array).expect("editable vector network"); - assert_eq!(segments.len(), 4, "network preserves the label gap as two disconnected runs"); - assert_eq!(alpha_children.iter().filter(|child| child["type"] == "TEXT").count(), 1); - let alpha_text = alpha_children.iter().find(|child| child["type"] == "TEXT").expect("label text"); - assert_eq!(alpha_text["characters"], "STRAIGHT alpha"); - assert_eq!(alpha_text["relativeTransform"], serde_json::json!([[1.0, 0.0, 201.5], [0.0, 1.0, 44.0]])); - assert!(warnings.iter().all(|warning| !warning.contains("connector labels were skipped"))); - - let mut malformed = message.clone(); - let alpha_node = connector_mut(&mut malformed, 127).expect("alpha node in decoded message"); - let derived = field_mut(alpha_node, "derivedImmutableFrameData").expect("derived frame data"); - let overrides = field_mut(derived, "overrides").expect("derived overrides"); - let Kv::Array(overrides) = overrides else { panic!("derived overrides array") }; - let text = overrides - .iter_mut() - .find(|row| { - row.get("guidPath") - .and_then(|path| path.get("guids")) - .and_then(|ids| ids.list().first()) - .and_then(|id| id.get("localID")) - .and_then(Kv::f64) - == Some(1.0) - }) - .expect("text template record"); - let transform = field_mut(text, "transform").expect("text transform"); - *transform = Kv::Object(vec![ - ("m00", Kv::Float(1.0)), - ("m01", Kv::Float(0.0)), - ("m02", Kv::Float(f32::NAN)), - ("m10", Kv::Float(0.0)), - ("m11", Kv::Float(1.0)), - ("m12", Kv::Float(0.0)), - ]); - let (malformed_rest, malformed_warnings) = map::to_rest(&malformed, "connector labels"); - let malformed_layers = malformed_rest.pointer("/document/children/0/children").and_then(serde_json::Value::as_array).expect("page layers"); - let malformed_alpha = connector(malformed_layers, "STRAIGHT alpha"); - assert_eq!(malformed_alpha["children"].as_array().map(Vec::len), Some(1)); - assert_eq!(malformed_alpha["children"][0]["type"], "VECTOR"); - assert!(malformed_warnings.iter().any(|warning| warning == "connector label was skipped: text transform is invalid")); -} From 9b4e45aa0bc8327673c4fc6e97915fca6b8cca68 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:40:55 +0700 Subject: [PATCH 067/118] #711: move the vector.offset tests into their own module --- crates/engine/src/lib.rs | 2 + crates/engine/src/tests.rs | 38 ------------------ crates/engine/src/vector_offset_tests.rs | 49 ++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 38 deletions(-) create mode 100644 crates/engine/src/vector_offset_tests.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..8609fb21 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1710,6 +1710,8 @@ mod vector_behaviour_tests; #[cfg(test)] mod vector_multi_behaviour_tests; #[cfg(test)] +mod vector_offset_tests; +#[cfg(test)] mod version_preview_tests; #[cfg(test)] mod version_tests; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 746f417e..dbf432d4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -621,41 +621,3 @@ fn find_replace_changing_only_the_case_replaces_every_occurrence() { assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft"); assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft"); } - -/// No node that holds children may be a non-container. -fn assert_children_only_in_containers(s: &Session, ids: &[NodeId]) { - let doc = s.doc().unwrap(); - for &i in ids { - let n = doc.node(i).unwrap(); - assert!(n.kind.is_container() || n.kind.is_frame_like() || n.children.is_empty(), "a non-container layer now holds children"); - } -} - -#[test] -fn offset_path_leaves_containers_with_children_alone() { - let mut s = sess(); - let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); - let inner = id(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10, "parent": f})).unwrap()); - let a = id(&s.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 10, "height": 10})).unwrap()); - let b = id(&s.execute("shape.rectangle", &json!({"x": 220, "y": 0, "width": 10, "height": 10})).unwrap()); - let g = id(&s.execute("object.group", &json!({"ids": [a, b]})).unwrap()); - for target in [f, g] { - s.execute("vector.offset", &json!({"ids": [target], "offset": 5})).unwrap(); - assert_children_only_in_containers(&s, &[f, g, inner, a, b]); - } - let doc = s.doc().unwrap(); - assert!(doc.node(f).unwrap().kind.is_container()); - assert_eq!(doc.node(f).unwrap().children, vec![inner]); - assert!(doc.node(g).unwrap().kind.is_container()); - assert_eq!(doc.node(g).unwrap().children.len(), 2); -} - -#[test] -fn offset_path_still_converts_a_plain_rectangle() { - let mut s = sess(); - let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); - s.execute("vector.offset", &json!({"ids": [r], "offset": 5})).unwrap(); - let n = s.doc().unwrap().node(r).unwrap().clone(); - assert!(matches!(n.kind, NodeKind::Vector { .. })); - assert!(n.width > 10.0); -} diff --git a/crates/engine/src/vector_offset_tests.rs b/crates/engine/src/vector_offset_tests.rs new file mode 100644 index 00000000..e47158f2 --- /dev/null +++ b/crates/engine/src/vector_offset_tests.rs @@ -0,0 +1,49 @@ +//! `vector.offset` on layers with children: containers are left alone. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use figcraft_doc::{NodeId, NodeKind}; + +fn id(v: &Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +/// No node that holds children may be a non-container. +fn assert_children_only_in_containers(s: &Session, ids: &[NodeId]) { + let doc = s.doc().unwrap(); + for &i in ids { + let n = doc.node(i).unwrap(); + assert!(n.kind.is_container() || n.kind.is_frame_like() || n.children.is_empty(), "a non-container layer now holds children"); + } +} + +#[test] +fn offset_path_leaves_containers_with_children_alone() { + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let inner = id(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10, "parent": f})).unwrap()); + let a = id(&s.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 10, "height": 10})).unwrap()); + let b = id(&s.execute("shape.rectangle", &json!({"x": 220, "y": 0, "width": 10, "height": 10})).unwrap()); + let g = id(&s.execute("object.group", &json!({"ids": [a, b]})).unwrap()); + for target in [f, g] { + s.execute("vector.offset", &json!({"ids": [target], "offset": 5})).unwrap(); + assert_children_only_in_containers(&s, &[f, g, inner, a, b]); + } + let doc = s.doc().unwrap(); + assert!(doc.node(f).unwrap().kind.is_container()); + assert_eq!(doc.node(f).unwrap().children, vec![inner]); + assert!(doc.node(g).unwrap().kind.is_container()); + assert_eq!(doc.node(g).unwrap().children.len(), 2); +} + +#[test] +fn offset_path_still_converts_a_plain_rectangle() { + let mut s = Session::new(); + let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + s.execute("vector.offset", &json!({"ids": [r], "offset": 5})).unwrap(); + let n = s.doc().unwrap().node(r).unwrap().clone(); + assert!(matches!(n.kind, NodeKind::Vector { .. })); + assert!(n.width > 10.0); +} From ee3e3f0bd423c3fc713f8e2a4672124313332931 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:44:34 +0700 Subject: [PATCH 068/118] #637: skip ink only counts under an underline The public docs don't give a default for skip ink; they say the value is unset when the text isn't underlined, and the exporter always wrote it on. So the export keeps writing on for text without an underline and the real value under one (that's what the canvas draws), and the import leaves a value on text without an underline out of the decoration look, so plain text keeps the default look and round-trips unchanged. The native default stays off, so existing documents draw as before. --- crates/format/src/fig/export.rs | 7 ++- crates/format/src/fig/interop_tests.rs | 3 +- crates/format/src/fig/text_skip_ink_tests.rs | 45 +++++++++++++++++--- crates/format/src/rest/text.rs | 7 +++ 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index dd3f2e88..475f8ceb 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -2392,13 +2392,14 @@ fn key_codes(key: &str) -> Option<(Vec, bool)> { Some((codes, false)) } -/// The text style fields of a layer or of an override table entry. +/// Whether the decoration look has more than skip ink, which the file can't hold. fn unsupported_decoration_look(st: &TextStyle) -> bool { let mut look = st.look(); look.skip_ink = false; look != Default::default() } +/// The text style fields of a layer or of an override table entry. fn style_fields(st: &TextStyle, f: &mut Fields) { let line_height = match st.line_height { // Percent units count from the font's own line height (100 % is "auto"); a share of the @@ -2430,7 +2431,9 @@ fn style_fields(st: &TextStyle, f: &mut Fields) { ("letterSpacing", letter_spacing), ("textCase", e(case)), ("textDecoration", e(decoration)), - ("textDecorationSkipInk", b(st.look().skip_ink)), + // Skip ink only shows on an underline; other text gets the files' default (on), the value + // the import leaves out again. + ("textDecorationSkipInk", b(st.look().skip_ink || st.decoration != TextDecoration::Underline)), ]); if let Some(url) = &st.link { f.push(("hyperlink", o(vec![("url", s(url))]))); diff --git a/crates/format/src/fig/interop_tests.rs b/crates/format/src/fig/interop_tests.rs index 56391975..b51760c5 100644 --- a/crates/format/src/fig/interop_tests.rs +++ b/crates/format/src/fig/interop_tests.rs @@ -187,8 +187,7 @@ fn text_carries_its_layout_settings_and_a_line_per_paragraph() { with_changes(&sample(), |of, _| { let t = of("TEXT"); assert!(!flag(&t, "autoRename")); - assert!(flag(&t, "fontVariantCommonLigatures") && flag(&t, "fontVariantContextualLigatures")); - assert!(!flag(&t, "textDecorationSkipInk"), "default native text exports its actual false value"); + assert!(flag(&t, "fontVariantCommonLigatures") && flag(&t, "fontVariantContextualLigatures") && flag(&t, "textDecorationSkipInk")); assert_eq!(name(&t, "fontVersion"), ""); assert_eq!((num(&t, "textUserLayoutVersion"), num(&t, "textExplicitLayoutVersion"), num(&t, "textBidiVersion")), (5.0, 1.0, 1.0)); assert_eq!(num(&t, "textTracking"), 0.0); diff --git a/crates/format/src/fig/text_skip_ink_tests.rs b/crates/format/src/fig/text_skip_ink_tests.rs index 8d1cd295..2a1ff61d 100644 --- a/crates/format/src/fig/text_skip_ink_tests.rs +++ b/crates/format/src/fig/text_skip_ink_tests.rs @@ -1,8 +1,9 @@ -//! `.fig` text skip-ink values map, inherit and round-trip as native decoration look state. +//! `.fig` text skip-ink values map, inherit and round-trip as native decoration look state. Skip +//! ink only shows on an underline, so the fixtures are underlined. use super::kiwi::Kv; use super::{export, import}; -use figcraft_doc::{Document, NodeKind, StyleKind, StyleRun, TextData, TextStyle}; +use figcraft_doc::{Document, NodeKind, StyleKind, StyleRun, TextData, TextDecoration, TextStyle}; use serde_json::Value; fn object(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { @@ -30,6 +31,7 @@ fn raw(base: Option>, runs: Vec>, chars: &str, ids: Vec< let mut layer = vec![ ("fontName", font()), ("fontSize", Kv::Float(20.0)), + ("textDecoration", Kv::Enum("UNDERLINE")), ( "textData", object(vec![ @@ -44,7 +46,8 @@ fn raw(base: Option>, runs: Vec>, chars: &str, ids: Vec< } let mut nodes = vec![node(1, "DOCUMENT", "Document", None, vec![]), node(2, "CANVAS", "Page", Some(1), vec![])]; if let Some(shared) = shared { - let mut fields = vec![("styleType", Kv::Enum("TEXT")), ("fontName", font()), ("fontSize", Kv::Float(20.0))]; + let mut fields = + vec![("styleType", Kv::Enum("TEXT")), ("fontName", font()), ("fontSize", Kv::Float(20.0)), ("textDecoration", Kv::Enum("UNDERLINE"))]; if let Some(value) = shared { fields.push(("textDecorationSkipInk", value)); } @@ -125,6 +128,7 @@ fn instance_path_override_preserves_explicit_false_over_true_main() { vec![ ("fontName", font()), ("fontSize", Kv::Float(20.0)), + ("textDecoration", Kv::Enum("UNDERLINE")), ("textDecorationSkipInk", Kv::Bool(true)), ("textData", object(vec![("characters", Kv::Str("A".into()))])), ], @@ -172,6 +176,7 @@ fn instance_path_override_preserves_explicit_false_over_true_main() { fn export_writes_one_actual_node_value_and_complete_run_values_without_skip_only_warning() { let mut doc = Document::new("Skip ink export"); let mut text = TextData::new("abcd"); + text.style.decoration = TextDecoration::Underline; text.style.set_look(|look| look.skip_ink = false); let mut skipped = text.style.clone(); skipped.set_look(|look| look.skip_ink = true); @@ -209,9 +214,9 @@ fn export_writes_one_actual_node_value_and_complete_run_values_without_skip_only let (_, warnings) = export(&variable_doc, None).unwrap(); assert!(warnings.iter().any(|w| w.contains("variable font settings")), "variation warning missing: {warnings:?}"); - let mut shared = TextStyle::default(); + let mut shared = TextStyle { decoration: TextDecoration::Underline, ..TextStyle::default() }; shared.set_look(|look| look.skip_ink = true); - let mut local = TextStyle::default(); + let mut local = TextStyle { decoration: TextDecoration::Underline, ..TextStyle::default() }; local.set_look(|look| look.skip_ink = false); let mut linked_doc = Document::new("Shared skip ink"); linked_doc.styles.push(figcraft_doc::Style { @@ -235,3 +240,33 @@ fn export_writes_one_actual_node_value_and_complete_run_values_without_skip_only let StyleKind::Text { style } = &linked.kind else { panic!("shared style link must remain text") }; assert!(style.look().skip_ink, "local false must not alter the shared definition"); } + +#[test] +fn skip_ink_without_an_underline_stays_out_of_the_look_and_exports_the_default() { + // Plain text with the files' default value imports with the default look... + let mut value = raw(Some(Kv::Bool(true)), vec![], "ab", vec![0, 0], None); + value["document"]["children"][0]["children"][0]["style"].as_object_mut().unwrap().remove("textDecoration"); + let doc = imported(&value); + assert_eq!(label(&doc).style.decoration, TextDecoration::None); + assert!(label(&doc).style.decoration_look.is_none()); + + // ...an underlined run of it keeps the layer's value... + let mut value = + raw(Some(Kv::Bool(true)), vec![object(vec![("styleID", Kv::Uint(1)), ("textDecoration", Kv::Enum("UNDERLINE"))])], "ab", vec![1, 0], None); + value["document"]["children"][0]["children"][0]["style"].as_object_mut().unwrap().remove("textDecoration"); + let doc = imported(&value); + assert_eq!((0..2).map(|i| skip_at(label(&doc), i)).collect::>(), vec![true, false]); + + // ...and default native text writes the default, which reads back as the default look. + let mut doc = Document::new("Plain"); + doc.create(doc.first_page().unwrap(), NodeKind::Text(TextData::new("x")), "Label", 0.0, 0.0, 100.0, 30.0).unwrap(); + let (bytes, _) = export(&doc, None).unwrap(); + let archive = super::Archive::open(&bytes).unwrap(); + let (schema, body) = super::decode_canvas(&archive.canvas).unwrap(); + let message = schema.decode_message(schema.find("Message").unwrap(), &body).unwrap(); + let value = super::debug_json(&message); + let layer = value["nodeChanges"].as_array().unwrap().iter().find(|n| n["name"] == "Label").unwrap(); + assert_eq!(layer["textDecorationSkipInk"], true); + let (back, _) = import(&bytes, "Plain").unwrap(); + assert!(label(&back).style.decoration_look.is_none()); +} diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index 3875204f..f607c292 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -27,6 +27,13 @@ pub(super) fn text_data(cx: &mut Ctx, v: &Value) -> TextData { let mut td = TextData { characters, style: base, ..TextData::default() }; paragraph(style_json, &mut td); td.runs = runs(cx, v, &td); + // Skip ink only shows on an underline. A file can carry it on any text, so elsewhere it stays out + // of the look (runs have inherited the base value by now). + for st in std::iter::once(&mut td.style).chain(td.runs.iter_mut().map(|r| &mut r.style)) { + if st.decoration != TextDecoration::Underline && st.look().skip_ink { + st.set_look(|l| l.skip_ink = false); + } + } td.normalize(); lists(v, &mut td); td From 6023d3547a2a8b056e2f3c0c68cefc45607cbdc9 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:45:15 +0700 Subject: [PATCH 069/118] #617: leave progress docs to the maintainer's docs PR --- docs/file-format-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index dcd75691..d8e51711 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -17,7 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, per-point stroke joins (strokes that are dashed or variable-width still draw with the layer's join, with a warning), end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout, constraints, grids, components, sets, instances with overrides, component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | From 7fe0770b9d74a072bdccae449edc347819617231 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:45:16 +0700 Subject: [PATCH 070/118] #617: point joins equal to the layer's no longer count as point styles (SVG keeps a plain stroke) --- crates/doc/src/vector.rs | 6 +++--- crates/pathops/src/points.rs | 3 +-- crates/render/src/draw.rs | 2 +- crates/svg/src/lib.rs | 2 +- crates/svg/src/tests.rs | 25 +++++++++++++++++++++++++ 5 files changed, 31 insertions(+), 7 deletions(-) diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index fd0a0f7e..1165bd60 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -468,9 +468,9 @@ impl VectorNetwork { start_dir(&self.outward(seg, at_a)?) } - /// Whether any point has a stroke cap or join of its own. - pub fn has_point_styles(&self) -> bool { - self.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some()) + /// Whether any point has a stroke cap of its own or a join other than the layer's `layer_join`. + pub fn has_point_styles(&self, layer_join: StrokeJoin) -> bool { + self.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some_and(|j| j != layer_join)) } /// The fill path: every region's loops. diff --git a/crates/pathops/src/points.rs b/crates/pathops/src/points.rs index 9c161b7c..05f58216 100644 --- a/crates/pathops/src/points.rs +++ b/crates/pathops/src/points.rs @@ -20,8 +20,7 @@ impl Styled { /// shortcut: dashed, variable-width and brush strokes keep the layer's caps and joins at every point; stroke them run by run too if asked for. /// Joins equal to the layer do not require styled runs; stored overrides remain unchanged. pub(crate) fn new(net: &VectorNetwork, props: &StrokeProps) -> Option { - let styled = net.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some_and(|join| join != props.join)); - if !styled || !props.dashes.is_empty() || props.profile().is_some() || props.brush.is_some() { + if !net.has_point_styles(props.join) || !props.dashes.is_empty() || props.profile().is_some() || props.brush.is_some() { return None; } let mut net = net.rounded().unwrap_or_else(|| net.clone()); diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 7bfafd33..021af92d 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -155,7 +155,7 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc { props.dashes.len().hash(&mut h); // Points' own caps and joins (vector networks). let net = match &n.kind { - NodeKind::Vector { network } if network.has_point_styles() => Some(network), + NodeKind::Vector { network } if network.has_point_styles(props.join) => Some(network), _ => None, }; for v in net.map(|n| n.vertices.as_slice()).unwrap_or_default() { diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index a6813017..1433b942 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -638,7 +638,7 @@ impl W<'_> { || k.brush.is_some() || k.cap_start.is_arrow() || k.cap_end.is_arrow() - || matches!(&n.kind, NodeKind::Vector { network } if network.has_point_styles()) + || matches!(&n.kind, NodeKind::Vector { network } if network.has_point_styles(k.join)) || !(w.is_finite() && w > 0.0) { return None; diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index b15cd999..5a333d07 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -115,3 +115,28 @@ fn text_run_fill_survives_live_text_export() { assert!(svg.contains("#000000"), "{svg}"); assert!(svg.contains("fill=\"#00FF00\"") && svg.contains(">b") && svg.contains(">a"), "{svg}"); } + +#[test] +fn point_joins_equal_to_the_layer_join_keep_a_plain_stroke() { + use figcraft_doc::{StrokeJoin, VectorNetwork}; + use figcraft_geom::kurbo::Point; + let svg_with = |join: StrokeJoin| { + let mut network = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(50.0, 50.0), Point::new(100.0, 0.0)], false); + for v in &mut network.vertices { + v.join = Some(StrokeJoin::Miter); + } + if let Some(v) = network.vertices.get_mut(1) { + v.join = Some(join); + } + let mut d = Document::new("t"); + let p = d.first_page().unwrap(); + let v = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 100.0, 50.0).unwrap(); + let n = d.get_mut(v).unwrap(); + n.strokes = vec![Paint::solid(Color::from_hex("000000").unwrap())]; + n.stroke.weight = 2.0; + n.stroke.join = StrokeJoin::Miter; + export_svg(&d, &[v], &SvgOptions::default()).unwrap() + }; + assert!(svg_with(StrokeJoin::Miter).contains("stroke-width=\"2\""), "equal joins stay a stroke"); + assert!(!svg_with(StrokeJoin::Round).contains("stroke-width="), "a differing join is drawn as an outline"); +} From d9e79d604b8d34fbea7eff367e90afdc67868c51 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:34:42 +0200 Subject: [PATCH 071/118] M1: distinguish unresolved vector segment style metadata in .fig import The vector network decoder now keeps each segment's style id. Import reports a segment style that names a row of the layer's style table as metadata that was not interpreted, and one that names no row as a reference to a missing style entry, each at most once per file. The earlier note claimed that stroke styles were left out, which overstated what is known about the metadata. Segment geometry, paint and width are unchanged, and the style ids are not kept in the native vector model. --- crates/format/src/fig/export.rs | 8 +++- crates/format/src/fig/map.rs | 25 ++++++++--- crates/format/src/fig/network.rs | 11 ++--- crates/format/src/fig/tail_tests.rs | 64 ++++++++++++++++++++++------- 4 files changed, 83 insertions(+), 25 deletions(-) diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index bfd0b615..7443c8de 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1669,7 +1669,13 @@ impl<'d> Exporter<'d> { let segments = net .segments .iter() - .map(|sg| network::Segment { a: sg.a, ta: (finite(sg.ta.0), finite(sg.ta.1)), b: sg.b, tb: (finite(sg.tb.0), finite(sg.tb.1)) }) + .map(|sg| network::Segment { + style: 0, + a: sg.a, + ta: (finite(sg.ta.0), finite(sg.ta.1)), + b: sg.b, + tb: (finite(sg.tb.0), finite(sg.tb.1)), + }) .collect(); let regions = net .regions diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 0fe44662..36f45f31 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1192,12 +1192,29 @@ impl<'a> Mapper<'a> { fn network(&mut self, o: &mut Obj, v: &View<'a>) -> bool { let Some(vd) = v.get("vectorData") else { return false }; let Some(blob) = num(vd, "vectorNetworkBlob").and_then(|i| self.blobs.get(i as usize)).copied() else { return false }; - let Some((net, styled_segments)) = network::decode_styled(blob) else { + let Some((net, _)) = network::decode_styled(blob) else { self.warn("damaged vector networks import as outlines"); return false; }; - if styled_segments > 0 { - self.warn("stroke styles of single vector network segments were left out"); + let styles: BTreeMap = + vd.get("styleOverrideTable").map(Kv::list).unwrap_or_default().iter().filter_map(|s| Some((num(s, "styleID")? as u64, s))).collect(); + let mut has_uninterpreted = false; + let mut has_missing = false; + for segment in &net.segments { + if segment.style == 0 { + continue; + } + if styles.contains_key(&u64::from(segment.style)) { + has_uninterpreted = true; + } else { + has_missing = true; + } + } + if has_uninterpreted { + self.warn("style settings of some vector segments aren't supported; those segments use the layer's stroke"); + } + if has_missing { + self.warn("some vector segments refer to style settings missing from the file; they use the layer's stroke"); } // An open path shows its fill, which regions can't hold: the outline keeps the look. let fills_shown = @@ -1205,8 +1222,6 @@ impl<'a> Mapper<'a> { if net.segments.is_empty() || (net.regions.is_empty() && fills_shown && o.contains_key("fillGeometry")) { return false; } - let styles: BTreeMap = - vd.get("styleOverrideTable").map(Kv::list).unwrap_or_default().iter().filter_map(|s| Some((num(s, "styleID")? as u64, s))).collect(); let style = |id: u32| styles.get(&u64::from(id)).copied().filter(|_| id != 0); let scale = |k: &str| { let size = v.size().map(|s| if k == "x" { s.0 } else { s.1 }); diff --git a/crates/format/src/fig/network.rs b/crates/format/src/fig/network.rs index d251dff1..4e8c3ad7 100644 --- a/crates/format/src/fig/network.rs +++ b/crates/format/src/fig/network.rs @@ -17,6 +17,7 @@ pub(super) struct Vertex { } pub(super) struct Segment { + pub style: u32, pub a: usize, pub ta: (f64, f64), pub b: usize, @@ -70,8 +71,7 @@ pub(super) fn decode(b: &[u8]) -> Option { decode_styled(b).map(|(net, _)| net) } -/// The network in blob `b` and how many of its segments have a style id of their own (per-segment -/// stroke overrides, which the document model can't hold); `None` as for `decode`. +/// The network in blob `b` and how many of its segments have a nonzero style id; `None` as for `decode`. pub(super) fn decode_styled(b: &[u8]) -> Option<(Network, usize)> { let mut r = Reader { b, pos: 0 }; let (nv, ns, nr) = (r.u32()? as usize, r.u32()? as usize, r.u32()? as usize); @@ -84,10 +84,11 @@ pub(super) fn decode_styled(b: &[u8]) -> Option<(Network, usize)> { } let mut styled = 0usize; for _ in 0..ns { - if r.u32()? != 0 { + let style = r.u32()?; + if style != 0 { styled += 1; } - net.segments.push(Segment { a: r.index(nv)?, ta: (r.f32()?, r.f32()?), b: r.index(nv)?, tb: (r.f32()?, r.f32()?) }); + net.segments.push(Segment { style, a: r.index(nv)?, ta: (r.f32()?, r.f32()?), b: r.index(nv)?, tb: (r.f32()?, r.f32()?) }); } let mut entries = 0usize; for _ in 0..nr { @@ -120,7 +121,7 @@ pub(super) fn encode(net: &Network) -> Vec { fl(v.y, &mut out); } for s in &net.segments { - u(0, &mut out); + u(s.style, &mut out); u(s.a as u32, &mut out); fl(s.ta.0, &mut out); fl(s.ta.1, &mut out); diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 5d34f23b..bc4f31cc 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -9,7 +9,7 @@ use super::super::network::{self, Network, Region, Segment, Vertex}; use super::*; fn seg(a: usize, b: usize, ta: (f64, f64), tb: (f64, f64)) -> Segment { - Segment { a, ta, b, tb } + Segment { style: 0, a, ta, b, tb } } fn vtx(style: u32, x: f64, y: f64) -> Vertex { @@ -264,21 +264,33 @@ fn vectors_import_as_editable_networks() { #[test] fn per_segment_stroke_styles_are_reported() { - let mut blob = square_blob(); - // The first segment's style id follows the counts and the vertices (12 bytes each). - blob[12 + 4 * 12..12 + 4 * 12 + 4].copy_from_slice(&7u32.to_le_bytes()); - let (net, styled) = network::decode_styled(&blob).unwrap(); - assert_eq!((net.segments.len(), styled), (4, 1)); + let styled_import = |segment_style: u32| { + let mut blob = square_blob(); + // The first segment's style id follows the counts and the vertices (12 bytes each). + blob[12 + 4 * 12..12 + 4 * 12 + 4].copy_from_slice(&segment_style.to_le_bytes()); + let (net, styled) = network::decode_styled(&blob).unwrap(); + assert_eq!((net.segments.len(), net.segments[0].style, styled), (4, segment_style, 1)); + let mut message = tail_message(); + let Kv::Object(fields) = &mut message else { panic!() }; + let Kv::Array(blobs) = &mut fields.iter_mut().find(|(key, _)| *key == "blobs").unwrap().1 else { panic!() }; + blobs[1] = o(vec![("bytes", Kv::Bytes(blob))]); + let (doc, warnings) = import(&canvas(&schema(), &message), "Tail").unwrap(); + assert_eq!(network_of(&doc, "Wave").segments.len(), 4); + warnings + }; assert_eq!(network::decode_styled(&square_blob()).unwrap().1, 0); let plain = import(&tail_canvas(), "Tail").unwrap().1; - assert!(!plain.iter().any(|w| w.contains("stroke styles of single")), "{plain:?}"); - let mut message = tail_message(); - let Kv::Object(fields) = &mut message else { panic!() }; - let Kv::Array(blobs) = &mut fields.iter_mut().find(|(key, _)| *key == "blobs").unwrap().1 else { panic!() }; - blobs[1] = o(vec![("bytes", Kv::Bytes(blob))]); - let (doc, warnings) = import(&canvas(&schema(), &message), "Tail").unwrap(); - assert!(warnings.iter().any(|w| w.contains("stroke styles of single vector network segments were left out")), "{warnings:?}"); - assert_eq!(network_of(&doc, "Wave").segments.len(), 4); + assert!(!plain.iter().any(|w| w.contains("vector segments")), "{plain:?}"); + let uninterpreted = |w: &String| w.contains("style settings of some vector segments aren't supported; those segments use the layer's stroke"); + let missing = |w: &String| w.contains("some vector segments refer to style settings missing from the file; they use the layer's stroke"); + // Style 1 has a row in the layer's override table; its contents are not applied. + let known = styled_import(1); + assert_eq!(known.iter().filter(|w| uninterpreted(w)).count(), 1, "{known:?}"); + assert!(!known.iter().any(missing), "{known:?}"); + // Style 7 names no row at all. + let unknown = styled_import(7); + assert_eq!(unknown.iter().filter(|w| missing(w)).count(), 1, "{unknown:?}"); + assert!(!unknown.iter().any(uninterpreted), "{unknown:?}"); } #[test] @@ -385,6 +397,30 @@ fn damaged_network_blobs_never_panic() { } } +#[test] +fn segment_styles_round_trip_without_changing_geometry() { + let original = Network { + vertices: vec![vtx(0, 2.0, 3.0), vtx(0, 10.0, 20.0), vtx(0, 40.0, 50.0), vtx(0, 60.0, 70.0)], + segments: vec![ + seg(0, 1, (1.0, 2.0), (3.0, 4.0)), + Segment { style: 17, a: 1, ta: (5.0, 6.0), b: 2, tb: (7.0, 8.0) }, + Segment { style: 27, a: 2, ta: (9.0, 10.0), b: 3, tb: (11.0, 12.0) }, + ], + regions: vec![], + }; + let bytes = network::encode(&original); + let (decoded, styled) = network::decode_styled(&bytes).unwrap(); + assert_eq!(styled, 2); + assert_eq!(decoded.segments.len(), 3); + for (got, want) in decoded.segments.iter().zip(&original.segments) { + assert_eq!(got.style, want.style); + assert_eq!((got.a, got.ta, got.b, got.tb), (want.a, want.ta, want.b, want.tb)); + } + for cut in 0..bytes.len() { + assert!(network::decode_styled(&bytes[..cut]).is_none(), "truncated at {cut}"); + } +} + #[test] fn mangled_tail_files_never_panic() { let c = tail_canvas(); From 22bdad22fe84f793a3b7b0b9491d31b61cebadfd Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:46:21 +0700 Subject: [PATCH 072/118] #656: leave progress docs to the maintainer's docs PR --- docs/file-format-parity.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c9e5da2f..d8e51711 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (`.fig` import: images carried in the message (`image.dataBlob`); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -17,7 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills (from the archive, or carried in the message itself as `image.dataBlob`, the way copies written by plugins and tools carry them) come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout, constraints, grids, components, sets, instances with overrides, component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | @@ -162,7 +162,6 @@ each. | Date | Change | Summary | |---|---|---| -| 2026-10-16 | minor | `.fig` import: image fills whose bytes travel in the message itself (`image.dataBlob`, an index into the message's blobs) come across. Copies written by plugins and tools carry their images this way, and a bare canvas, the shape the clipboard holds, has no `images/`: they were grey placeholders. Blobs over the image size cap and indexes past the blobs are skipped (placeholder, as before). Checked on a bare canvas generated in code and on copies written by an HTML-to-Figma browser extension. Numbers unchanged | | 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. | | 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Opened `.fig` and file JSON documents link nested instances of variants (and any nested instance under a name of its own) to their main's layers; they stayed unlinked, so a second export lost what they changed. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | | 2026-10-12 | minor | `.fig` export made to open elsewhere (gap 1): the document and its pages say they are visible and opaque (left out, nothing was drawn), the schema in the file uses the format's own names, kinds, field ids and enum values, rectangles are rounded rectangles with four radii, frames and shapes carry their fill outline and plain strokes their stroke outline, text carries its layout settings, font styles use their standard names, line heights that are a share of the font size are raw multipliers, masks keep their type (read back too); the import reads per-side stroke weights whose zero sides are left out. Written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers unchanged | From 99dc199bb89d2c0f7dfb6f508169e3cf78280073 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:46:22 +0700 Subject: [PATCH 073/118] #656: borrow embedded image bytes and copy only the ones a paint uses --- crates/format/src/fig/mod.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 091492da..90cdd05d 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -66,7 +66,7 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), Forma let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name); let (json, mut warnings) = map::to_rest(&msg, file_name); let embedded = embedded_images(&msg); - let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).cloned()); + let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).map(|b| b.to_vec())); let (doc, more) = crate::rest::import(&json, &images)?; warnings.extend(more.into_iter().filter_map(map::adapt_warning)); Ok((doc, warnings)) @@ -100,8 +100,9 @@ fn corrupt(msg: impl Into) -> FormatError { /// Image bytes that travel inside the message, by image hash (lowercase hex): a paint's /// `image.dataBlob` indexes the message's `blobs`. Copies written by plugins and other tools embed /// their images this way, and a bare canvas (what the clipboard carries) has no `images/`. The -/// walk is iterative (no recursion on nested data) and skips blobs larger than [`MAX_IMAGE`]. -fn embedded_images(msg: &Kv) -> BTreeMap> { +/// walk is iterative (no recursion on nested data) and skips blobs larger than [`MAX_IMAGE`]. The +/// bytes stay borrowed from the message; only images a paint asks for are copied. +fn embedded_images<'m>(msg: &'m Kv) -> BTreeMap { let blobs = msg.get("blobs").map(Kv::list).unwrap_or_default(); let mut found = BTreeMap::new(); let mut pending = vec![msg]; @@ -109,7 +110,7 @@ fn embedded_images(msg: &Kv) -> BTreeMap> { match value { Kv::Object(fields) => { if let Some((hash, bytes)) = value.get("image").and_then(|image| embedded_image(image, blobs)) { - found.entry(hash).or_insert_with(|| bytes.to_vec()); + found.entry(hash).or_insert(bytes); } pending.extend(fields.iter().filter(|(name, _)| *name != "blobs").map(|(_, field)| field)); } From 5fba7e5dba98a275e858713289ed4f9fd3363672 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:48:56 +0700 Subject: [PATCH 074/118] #524: one kiwi value per compressed canvas byte (up to 128M), lower value and document.json ceilings on 32-bit targets --- crates/format/src/fig/kiwi.rs | 14 +++++++++----- crates/format/src/fig/tests.rs | 2 ++ crates/format/src/large_tests.rs | 4 +++- crates/format/src/lib.rs | 4 ++++ 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs index 46a9daac..5c735b28 100644 --- a/crates/format/src/fig/kiwi.rs +++ b/crates/format/src/fig/kiwi.rs @@ -11,11 +11,15 @@ use std::fmt; /// Deepest struct/message nesting decoded. const MAX_DEPTH: usize = 64; /// Most values decoded from one message (every scalar, array element and field counts): at least -/// [`MIN_VALUES`], and one per [`PACKED_BYTES_PER_VALUE`] bytes of the file's compressed canvas, -/// so a large real file decodes while a small one can't expand into a huge tree, however far its -/// chunks inflate. +/// [`MIN_VALUES`], and one per byte of the file's compressed canvas up to [`MAX_VALUES`], so a +/// large real file decodes while a small one can't expand into a huge tree, however far its +/// chunks inflate. 32-bit targets (wasm32) have a 4 GB address space in all, so their ceiling is +/// lower: past it the file is refused cleanly instead of running out of memory. const MIN_VALUES: usize = 20_000_000; -const PACKED_BYTES_PER_VALUE: usize = 2; +#[cfg(not(target_pointer_width = "32"))] +const MAX_VALUES: usize = 128 << 20; +#[cfg(target_pointer_width = "32")] +const MAX_VALUES: usize = 32 << 20; /// Most definitions in a schema, and fields in one definition. const MAX_DEFS: usize = 10_000; const MAX_FIELDS: usize = 10_000; @@ -302,7 +306,7 @@ impl Schema { /// Most values decoded from a file whose compressed canvas is `packed` bytes. pub(crate) fn value_budget(packed: usize) -> usize { - MIN_VALUES.max(packed / PACKED_BYTES_PER_VALUE) + packed.clamp(MIN_VALUES, MAX_VALUES) } struct Decoder<'s, 'b> { diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 574f860c..ad9c85d0 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -629,6 +629,8 @@ fn large_files_get_a_larger_value_budget() { // A real 400 000-layer file: an 83.5 MB canvas whose message unpacks to 334 MB and holds // 30.5 million values was refused as "too many values". assert!(kiwi::value_budget(83_491_232) > 30_514_899); + // A real 194 MB design-system file (UX4G): a 39.5 MB canvas holding 29.7 million values. + assert!(kiwi::value_budget(39_528_420) > 29_703_730); // Still bounded by the largest canvas read. assert!(kiwi::value_budget(MAX_CANVAS as usize) <= 128 << 20); } diff --git a/crates/format/src/large_tests.rs b/crates/format/src/large_tests.rs index 2dc9eb4c..889074f1 100644 --- a/crates/format/src/large_tests.rs +++ b/crates/format/src/large_tests.rs @@ -211,7 +211,9 @@ fn document_json_may_unpack_in_proportion_to_its_packed_size() { assert_eq!(document_limit(MB, 2 * MB), MAX_MEMBER); // A real 900 000-layer document saved by us packs 1 477 139 810 bytes into 244 362 923, and // didn't open again under the 512 MB cap. - assert!(document_limit(244_362_923, 260 * MB) > 1_477_139_810); + if cfg!(not(target_pointer_width = "32")) { + assert!(document_limit(244_362_923, 260 * MB) > 1_477_139_810); + } // The packed size an archive claims counts only up to the archive's own size. assert_eq!(document_limit(u64::MAX, MB), MAX_MEMBER); // Never more than the ceiling. diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index dd96b932..9f7e51bc 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -42,8 +42,12 @@ const MAX_MEMBER: u64 = 512 * 1024 * 1024; /// `document.json` may unpack to [`DOCUMENT_RATIO`] times what it takes in the archive, within /// [`MAX_MEMBER`] and [`MAX_DOCUMENT`]: a large document saved by us (a 900 000-layer file packs /// 1.48 GB into 244 MB) opens again, while a small archive still can't unpack into gigabytes. +/// 32-bit targets (wasm32) have a 4 GB address space in all, so their ceiling is lower. const DOCUMENT_RATIO: u64 = 16; +#[cfg(not(target_pointer_width = "32"))] const MAX_DOCUMENT: u64 = 4 * 1024 * 1024 * 1024; +#[cfg(target_pointer_width = "32")] +const MAX_DOCUMENT: u64 = 1024 * 1024 * 1024; /// Most versions a file keeps: saving more is refused, extra entries in a file are skipped. pub const MAX_VERSIONS: usize = 100; /// Longest version name and description kept (chars). From c605bd5781157431b756768e58add61a200d9fb6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:49:46 +0700 Subject: [PATCH 075/118] #631: drop the styled-segment count nobody reads --- crates/format/src/fig/map.rs | 2 +- crates/format/src/fig/network.rs | 12 +++--------- crates/format/src/fig/tail_tests.rs | 23 +++++++++++------------ 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 36f45f31..94678e25 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1192,7 +1192,7 @@ impl<'a> Mapper<'a> { fn network(&mut self, o: &mut Obj, v: &View<'a>) -> bool { let Some(vd) = v.get("vectorData") else { return false }; let Some(blob) = num(vd, "vectorNetworkBlob").and_then(|i| self.blobs.get(i as usize)).copied() else { return false }; - let Some((net, _)) = network::decode_styled(blob) else { + let Some(net) = network::decode(blob) else { self.warn("damaged vector networks import as outlines"); return false; }; diff --git a/crates/format/src/fig/network.rs b/crates/format/src/fig/network.rs index 3adb0abf..ccb5c291 100644 --- a/crates/format/src/fig/network.rs +++ b/crates/format/src/fig/network.rs @@ -65,10 +65,8 @@ impl Reader<'_> { } } -/// The network in blob `b` and how many of its segments have a style id of their own (per-segment -/// stroke overrides, which the document model can't hold); `None` when it's truncated, out of -/// range or not finite. -pub(super) fn decode_styled(b: &[u8]) -> Option<(Network, usize)> { +/// The network in blob `b`; `None` when it's truncated, out of range or not finite. +pub(super) fn decode(b: &[u8]) -> Option { let mut r = Reader { b, pos: 0 }; let (nv, ns, nr) = (r.u32()? as usize, r.u32()? as usize, r.u32()? as usize); if nv > MAX_ITEMS || ns > MAX_ITEMS || nr > MAX_ITEMS || nv * 12 + ns * 28 + nr * 8 > b.len().saturating_sub(12) { @@ -78,12 +76,8 @@ pub(super) fn decode_styled(b: &[u8]) -> Option<(Network, usize)> { for _ in 0..nv { net.vertices.push(Vertex { style: r.u32()?, x: r.f32()?, y: r.f32()? }); } - let mut styled = 0usize; for _ in 0..ns { let style = r.u32()?; - if style != 0 { - styled += 1; - } net.segments.push(Segment { style, a: r.index(nv)?, ta: (r.f32()?, r.f32()?), b: r.index(nv)?, tb: (r.f32()?, r.f32()?) }); } let mut entries = 0usize; @@ -100,7 +94,7 @@ pub(super) fn decode_styled(b: &[u8]) -> Option<(Network, usize)> { } net.regions.push(Region { style: sw >> 1, non_zero: sw & 1 == 1, loops }); } - Some((net, styled)) + Some(net) } /// The blob of `net` (the export writes it; so do test fixtures). Callers pass finite values. diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 363d2bdf..a15334a5 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -268,8 +268,8 @@ fn per_segment_stroke_styles_are_reported() { let mut blob = square_blob(); // The first segment's style id follows the counts and the vertices (12 bytes each). blob[12 + 4 * 12..12 + 4 * 12 + 4].copy_from_slice(&segment_style.to_le_bytes()); - let (net, styled) = network::decode_styled(&blob).unwrap(); - assert_eq!((net.segments.len(), net.segments[0].style, styled), (4, segment_style, 1)); + let net = network::decode(&blob).unwrap(); + assert_eq!((net.segments.len(), net.segments[0].style), (4, segment_style)); let mut message = tail_message(); let Kv::Object(fields) = &mut message else { panic!() }; let Kv::Array(blobs) = &mut fields.iter_mut().find(|(key, _)| *key == "blobs").unwrap().1 else { panic!() }; @@ -278,7 +278,7 @@ fn per_segment_stroke_styles_are_reported() { assert_eq!(network_of(&doc, "Wave").segments.len(), 4); warnings }; - assert_eq!(network::decode_styled(&square_blob()).unwrap().1, 0); + assert!(network::decode(&square_blob()).unwrap().segments.iter().all(|s| s.style == 0)); let plain = import(&tail_canvas(), "Tail").unwrap().1; assert!(!plain.iter().any(|w| w.contains("vector segments")), "{plain:?}"); let uninterpreted = |w: &String| w.contains("style settings of some vector segments aren't supported; those segments use the layer's stroke"); @@ -367,9 +367,9 @@ fn unreadable_conditions_skip_the_conditional() { #[test] fn damaged_network_blobs_never_panic() { let good = square_blob(); - assert!(network::decode_styled(&good).is_some()); + assert!(network::decode(&good).is_some()); for cut in 0..good.len() { - assert!(network::decode_styled(&good[..cut]).is_none(), "{cut}"); + assert!(network::decode(&good[..cut]).is_none(), "{cut}"); } let mut r = Lcg(5); for _ in 0..5000 { @@ -378,7 +378,7 @@ fn damaged_network_blobs_never_panic() { let i = (r.next() as usize) % m.len(); m[i] ^= 1 << (r.next() % 8); } - if let Some((net, _)) = network::decode_styled(&m) { + if let Some(net) = network::decode(&m) { assert!(net.segments.iter().all(|s| s.a < net.vertices.len() && s.b < net.vertices.len())); assert!(net.regions.iter().flat_map(|r| &r.loops).flatten().all(|i| *i < net.segments.len())); assert!(net.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite())); @@ -387,13 +387,13 @@ fn damaged_network_blobs_never_panic() { // Counts larger than the blob, and indices out of range. let mut huge = vec![0xff; 12]; huge.extend_from_slice(&[0; 64]); - assert!(network::decode_styled(&huge).is_none()); + assert!(network::decode(&huge).is_none()); let mut bad = arrow_blob(); bad[12 + 24 + 4..12 + 24 + 8].copy_from_slice(&7u32.to_le_bytes()); - assert!(network::decode_styled(&bad).is_none()); + assert!(network::decode(&bad).is_none()); for _ in 0..2000 { let junk: Vec = (0..(r.next() % 120)).map(|_| r.next() as u8).collect(); - let _ = network::decode_styled(&junk); + let _ = network::decode(&junk); } } @@ -409,15 +409,14 @@ fn segment_styles_round_trip_without_changing_geometry() { regions: vec![], }; let bytes = network::encode(&original); - let (decoded, styled) = network::decode_styled(&bytes).unwrap(); - assert_eq!(styled, 2); + let decoded = network::decode(&bytes).unwrap(); assert_eq!(decoded.segments.len(), 3); for (got, want) in decoded.segments.iter().zip(&original.segments) { assert_eq!(got.style, want.style); assert_eq!((got.a, got.ta, got.b, got.tb), (want.a, want.ta, want.b, want.tb)); } for cut in 0..bytes.len() { - assert!(network::decode_styled(&bytes[..cut]).is_none(), "truncated at {cut}"); + assert!(network::decode(&bytes[..cut]).is_none(), "truncated at {cut}"); } } From 6233b90ba7684fc58af66804176c3e54dfa954fe Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:36:24 +0200 Subject: [PATCH 076/118] M1: import editable text on a path from .fig files Supported TEXT_PATH layers import as editable native text on their vector path, including mixed text styles, a start inside a later segment and reversed text direction. The importer converts the segment parameter stored in the file to the native distance along the complete path. The mapping accepts a connected, unbranched path with one recognized start-segment marker and the default placement above the path. Damaged or unsupported path data keeps the text readable off its path and adds an import note. Other vertical placements, multiline text and ambiguous or disconnected networks are not converted. Underline and strikethrough remain a native path-rendering limitation and are reported. Public REST TEXT_PATH layers and the experimental .fig writer are unchanged. --- crates/format/src/fig/map.rs | 95 ++++++- crates/format/src/fig/mod.rs | 3 + crates/format/src/fig/text_path.rs | 315 +++++++++++++++++++++++ crates/format/src/fig/text_path_tests.rs | 211 +++++++++++++++ crates/format/src/rest/text.rs | 12 +- docs/gaps.md | 4 + 6 files changed, 637 insertions(+), 3 deletions(-) create mode 100644 crates/format/src/fig/text_path.rs create mode 100644 crates/format/src/fig/text_path_tests.rs diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 94678e25..9a2b696d 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -759,7 +759,7 @@ impl<'a> Mapper<'a> { "VARIABLE" | "VARIABLE_SET" => return None, t => stand.map_or(t, |s| s.0), }; - if let Some((_, what)) = stand { + if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH") { self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " "))); } self.count += 1; @@ -774,7 +774,12 @@ impl<'a> Mapper<'a> { copy_str(&mut o, &view, "name", "name"); self.common(&mut o, &view, ty, space, parent); match ty { - "TEXT" => self.text(&mut o, &view), + "TEXT" => { + self.text(&mut o, &view); + if own == "TEXT_PATH" { + self.text_path(&mut o, &view); + } + } "VECTOR" | "STAR" | "REGULAR_POLYGON" => self.geometry(&mut o, &view), "ELLIPSE" => { if let Some(a) = view.get("arcData") { @@ -1155,6 +1160,92 @@ impl<'a> Mapper<'a> { } } + /// Carry supported text-path data through the private .fig REST ingress only. + fn text_path(&mut self, o: &mut Obj, v: &View<'a>) { + let fail = |this: &mut Self, reason: &str| { + this.warn(format!("text path {reason}; text imports off its path")); + }; + let Some(start) = v.get("textPathStart") else { + fail(self, "has no start data"); + return; + }; + let (Some(t), Some(forward)) = (num(start, "tValue"), start.get("forward").and_then(Kv::bool)) else { + fail(self, "has invalid start data"); + return; + }; + let Some(vd) = v.get("vectorData") else { + fail(self, "has no vector data"); + return; + }; + let Some(blob) = num(vd, "vectorNetworkBlob") + .filter(|i| i.is_finite() && *i >= 0.0 && i.fract() == 0.0) + .and_then(|i| usize::try_from(i as u64).ok()) + .and_then(|i| self.blobs.get(i)) + .copied() + else { + fail(self, "has no vector network"); + return; + }; + let Some((net, _)) = network::decode_styled(blob) else { + fail(self, "has damaged vector data"); + return; + }; + let Some(norm) = vd.get("normalizedSize") else { + fail(self, "has invalid normalized size"); + return; + }; + let Some(size) = v.get("size") else { + fail(self, "has invalid dimensions"); + return; + }; + let (Some(nx), Some(ny), Some(w), Some(h)) = (num(norm, "x"), num(norm, "y"), num(size, "x"), num(size, "y")) else { + fail(self, "has invalid dimensions"); + return; + }; + let axis_scale = |size: f64, normalized: f64, values: &[f64]| -> Option { + if !size.is_finite() || size < 0.0 || !normalized.is_finite() || normalized < 0.0 { + return None; + } + if normalized > 1e-9 { + if size <= 0.0 { + return None; + } + return Some(size / normalized); + } + (size == 0.0 && values.iter().all(|v| *v == 0.0)).then_some(1.0) + }; + let xs: Vec = net.vertices.iter().map(|p| p.x).chain(net.segments.iter().flat_map(|s| [s.ta.0, s.tb.0])).collect(); + let ys: Vec = net.vertices.iter().map(|p| p.y).chain(net.segments.iter().flat_map(|s| [s.ta.1, s.tb.1])).collect(); + let sx = axis_scale(w, nx, &xs); + let sy = axis_scale(h, ny, &ys); + let (Some(sx), Some(sy)) = (sx, sy) else { + fail(self, "has invalid path scale"); + return; + }; + if v.str("textAlignVertical").is_some_and(|x| x != "TOP") { + fail(self, "uses unsupported vertical placement"); + return; + } + if v.get("textData").and_then(|td| td.get("characters")).and_then(Kv::str).is_some_and(|s| s.chars().any(|c| matches!(c, '\n' | '\r'))) { + fail(self, "contains multiple lines"); + return; + } + let path = match super::text_path::from_network(&net, sx, sy, t, forward) { + Ok(path) => path, + Err(reason) => { + fail(self, reason); + return; + } + }; + let has_decoration = |style: &Value| style.get("textDecoration").and_then(Value::as_str).is_some_and(|x| x != "NONE"); + let decorated = o.get("style").is_some_and(has_decoration) + || o.get("styleOverrideTable").and_then(Value::as_object).into_iter().flat_map(|styles| styles.values()).any(has_decoration); + if decorated { + self.warn("text path underline or strikethrough decoration is not rendered"); + } + o.insert("_figTextPath".into(), json!({"d": path.d, "start": path.start, "flip": path.flip})); + } + /// `fillGeometry` / `strokeGeometry` as SVG path data, and star/polygon parameters. fn geometry(&mut self, o: &mut Obj, v: &View<'a>) { for key in ["fillGeometry", "strokeGeometry"] { diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 81457f8d..2ad6e276 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -24,6 +24,9 @@ mod network; mod source_identity_tests; #[cfg(test)] mod tests; +mod text_path; +#[cfg(test)] +mod text_path_tests; #[cfg(test)] mod text_style_tests; diff --git a/crates/format/src/fig/text_path.rs b/crates/format/src/fig/text_path.rs new file mode 100644 index 00000000..8a93f7e4 --- /dev/null +++ b/crates/format/src/fig/text_path.rs @@ -0,0 +1,315 @@ +//! Convert the simple vector network used by text-on-path layers in `.fig` files into a native text path. + +use figcraft_doc::{ + TextPath, + geom::{ + BezPath, + kurbo::{CubicBez, Line, ParamCurve, ParamCurveArclen, Point}, + }, +}; + +use super::network::{Network, Segment}; + +const START_SEGMENT_STYLE: u32 = 0x01ff_ffff; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Use { + segment: usize, + forward: bool, +} + +/// Build a native path from a validated connected chain or cycle. The marked source segment is +/// oriented a→b; `t` is local to that line/cubic. Coordinates are scaled into layer space. +pub(super) fn from_network(net: &Network, sx: f64, sy: f64, t: f64, forward: bool) -> Result { + if !t.is_finite() || !(0.0..=1.0).contains(&t) { + return Err("invalid start parameter"); + } + if !sx.is_finite() || !sy.is_finite() || sx <= 0.0 || sy <= 0.0 { + return Err("invalid path scale"); + } + let markers: Vec = net.segments.iter().enumerate().filter_map(|(i, s)| (s.style == START_SEGMENT_STYLE).then_some(i)).collect(); + let [marked] = markers.as_slice() else { + return Err("missing or ambiguous start segment marker"); + }; + + let (uses, closed) = ordered_chain(net, *marked)?; + let mut d = BezPath::new(); + let mut total = 0.0; + let mut prefix = 0.0; + let mut start_segment_length = 0.0; + for use_ in &uses { + let (p0, p1, p2, p3) = cubic(net, *use_, sx, sy)?; + if d.elements().is_empty() { + d.move_to((p0.0, p0.1)); + } + let line = p1 == p0 && p2 == p3; + let (length, partial) = if line { + d.line_to((p3.0, p3.1)); + let line = Line::new(Point::new(p0.0, p0.1), Point::new(p3.0, p3.1)); + (line.arclen(1e-9), line.arclen(1e-9) * t) + } else { + d.curve_to((p1.0, p1.1), (p2.0, p2.1), (p3.0, p3.1)); + let curve = CubicBez::new((p0.0, p0.1), (p1.0, p1.1), (p2.0, p2.1), (p3.0, p3.1)); + let partial = if t <= 0.0 { 0.0 } else { curve.subsegment(0.0..t).arclen(1e-9) }; + (curve.arclen(1e-9), partial) + }; + if !length.is_finite() || length <= 1e-10 { + return Err("zero-length path segment"); + } + if use_.segment == *marked { + start_segment_length = partial; + if !start_segment_length.is_finite() { + return Err("invalid path length"); + } + prefix = total; + } + total += length; + } + if closed { + d.close_path(); + } + if !total.is_finite() || total <= 1e-10 { + return Err("zero-length path"); + } + let mut start = ((prefix + start_segment_length) / total).clamp(0.0, 1.0); + let flip = !forward; + if flip { + start = 1.0 - start; + } + let path = TextPath { d: d.to_svg(), start, flip }; + let lines = figcraft_doc::geom::measure::polylines(&path.path()).ok_or("cannot be measured by text renderer")?; + if lines.first().is_none_or(|line| line.length() <= 1e-9) { + return Err("cannot be measured by text renderer"); + } + Ok(path) +} + +/// Return every segment in chain order. Open chains are oriented so the marked segment runs a→b; +/// cycles start at the marked segment's a vertex and traverse it first. +fn ordered_chain(net: &Network, marked: usize) -> Result<(Vec, bool), &'static str> { + let marker = net.segments.get(marked).ok_or("invalid start segment index")?; + let mut adjacency = vec![Vec::::new(); net.vertices.len()]; + for (i, s) in net.segments.iter().enumerate() { + if s.a >= adjacency.len() || s.b >= adjacency.len() { + return Err("invalid path vertex index"); + } + if s.a == s.b { + return Err("path contains a segment that starts and ends at the same vertex"); + } + adjacency[s.a].push(i); + adjacency[s.b].push(i); + } + let mut endpoints = Vec::new(); + for edges in &adjacency { + if edges.len() > 2 { + return Err("branched path network"); + } + if edges.len() == 1 { + endpoints.push(edges[0]); + } + } + if !endpoints.is_empty() && endpoints.len() != 2 { + return Err("disconnected or malformed path network"); + } + let cycle = endpoints.is_empty(); + let mut used = vec![false; net.segments.len()]; + let mut out = Vec::with_capacity(net.segments.len()); + if cycle { + out.push(Use { segment: marked, forward: true }); + used[marked] = true; + let mut at = marker.b; + let mut visited = vec![false; net.vertices.len()]; + visited[marker.a] = true; + visited[marker.b] = true; + while at != marker.a { + let next = adjacency[at].iter().copied().find(|i| !used[*i]).ok_or("disconnected path cycle")?; + used[next] = true; + let s = &net.segments[next]; + let is_forward = s.a == at; + at = if is_forward { s.b } else { s.a }; + if at != marker.a && std::mem::replace(&mut visited[at], true) { + return Err("path cycle is not simple"); + } + out.push(Use { segment: next, forward: is_forward }); + if out.len() > net.segments.len() { + return Err("invalid path cycle"); + } + } + } else { + let endpoint = adjacency.iter().position(|e| e.len() == 1).ok_or("missing path endpoint")?; + let mut at = endpoint; + while let Some(next) = adjacency[at].iter().copied().find(|i| !used[*i]) { + used[next] = true; + let s = &net.segments[next]; + let is_forward = s.a == at; + at = if is_forward { s.b } else { s.a }; + out.push(Use { segment: next, forward: is_forward }); + } + let Some(marker_use) = out.iter().find(|u| u.segment == marked) else { return Err("disconnected start segment") }; + if !marker_use.forward { + out.reverse(); + for u in &mut out { + u.forward = !u.forward; + } + } + } + if !used.iter().all(|x| *x) { + return Err("disconnected path network"); + } + Ok((out, cycle)) +} + +type P = (f64, f64); + +fn cubic(net: &Network, use_: Use, sx: f64, sy: f64) -> Result<(P, P, P, P), &'static str> { + let s: &Segment = net.segments.get(use_.segment).ok_or("invalid path segment index")?; + let a = net.vertices.get(s.a).ok_or("invalid path vertex index")?; + let b = net.vertices.get(s.b).ok_or("invalid path vertex index")?; + let p0 = (a.x * sx, a.y * sy); + let p3 = (b.x * sx, b.y * sy); + let ta = (s.ta.0 * sx, s.ta.1 * sy); + let tb = (s.tb.0 * sx, s.tb.1 * sy); + if [p0.0, p0.1, p3.0, p3.1, ta.0, ta.1, tb.0, tb.1].iter().any(|x| !x.is_finite()) { + return Err("invalid path coordinates"); + } + let (p0, p1, p2, p3) = + if ta == (0.0, 0.0) && tb == (0.0, 0.0) { (p0, p0, p3, p3) } else { (p0, (p0.0 + ta.0, p0.1 + ta.1), (p3.0 + tb.0, p3.1 + tb.1), p3) }; + if use_.forward { Ok((p0, p1, p2, p3)) } else { Ok((p3, p2, p1, p0)) } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fig::network::{Segment, Vertex}; + use figcraft_doc::geom::kurbo::PathEl; + + fn vertex(x: f64, y: f64) -> Vertex { + Vertex { style: 0, x, y } + } + + fn segment(style: u32, a: usize, b: usize, ta: (f64, f64), tb: (f64, f64)) -> Segment { + Segment { style, a, ta, b, tb } + } + + fn curved() -> Network { + Network { + vertices: vec![vertex(0.0, 82.5), vertex(300.0, 82.5), vertex(600.0, 82.5)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (70.0, -110.0), (-70.0, -110.0)), segment(0, 1, 2, (70.0, 110.0), (-70.0, 110.0))], + ..Network::default() + } + } + + #[test] + fn local_cubic_parameter_converts_to_path_distance() { + let path = from_network(&curved(), 1.0, 1.0, 0.0086954795, true).unwrap(); + assert!((path.start - 0.004744078).abs() < 0.00001); + // Cubic t is not its fraction of total path length. + assert!((path.start - 0.0086954795).abs() > 0.003); + let elements = path.path().elements().to_vec(); + assert!(matches!(elements.first(), Some(PathEl::MoveTo(p)) if p.x == 0.0 && p.y == 82.5)); + assert!(matches!(elements.get(1), Some(PathEl::CurveTo(..)))); + } + + #[test] + fn second_segment_marker_and_t_are_respected() { + let mut net = curved(); + net.segments[0].style = 0; + net.segments[1].style = START_SEGMENT_STYLE; + let path = from_network(&net, 1.0, 1.0, 0.49800667, true).unwrap(); + assert!((path.start - 0.7490379757).abs() < 0.00002); + } + + #[test] + fn reverse_direction_inverts_normalized_start() { + let net = Network { + vertices: vec![vertex(0.0, 0.0), vertex(600.0, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + let path = from_network(&net, 1.0, 1.0, 0.83986014, false).unwrap(); + assert!(path.flip); + assert!((path.start - 0.16013986).abs() < 1e-8); + assert!(matches!(path.path().elements().get(1), Some(PathEl::LineTo(p)) if p.x == 600.0 && p.y == 0.0)); + } + + #[test] + fn rejects_missing_duplicate_and_zero_length_segments() { + let mut net = curved(); + net.segments[0].style = 0; + assert_eq!(from_network(&net, 1.0, 1.0, 0.5, true), Err("missing or ambiguous start segment marker")); + net.segments[1].style = START_SEGMENT_STYLE; + net.segments[0].style = START_SEGMENT_STYLE; + assert_eq!(from_network(&net, 1.0, 1.0, 0.5, true), Err("missing or ambiguous start segment marker")); + let zero = Network { + vertices: vec![vertex(0.0, 0.0), vertex(0.0, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + assert_eq!(from_network(&zero, 1.0, 1.0, 0.5, true), Err("zero-length path segment")); + } + + #[test] + fn rejects_branching_and_disconnected_segments() { + let branch = Network { + vertices: vec![vertex(0.0, 0.0), vertex(1.0, 0.0), vertex(2.0, 0.0), vertex(1.0, 1.0)], + segments: vec![ + segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)), + segment(0, 1, 2, (0.0, 0.0), (0.0, 0.0)), + segment(0, 1, 3, (0.0, 0.0), (0.0, 0.0)), + ], + ..Network::default() + }; + assert_eq!(from_network(&branch, 1.0, 1.0, 0.5, true), Err("branched path network")); + let disconnected = Network { + vertices: vec![vertex(0.0, 0.0), vertex(1.0, 0.0), vertex(2.0, 0.0), vertex(3.0, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)), segment(0, 2, 3, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + assert_eq!(from_network(&disconnected, 1.0, 1.0, 0.5, true), Err("disconnected or malformed path network")); + } + + #[test] + fn scales_geometry_and_accepts_closed_cycles() { + let cycle = Network { + vertices: vec![vertex(0.0, 0.0), vertex(100.0, 0.0), vertex(100.0, 100.0)], + segments: vec![ + segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)), + segment(0, 1, 2, (0.0, 0.0), (0.0, 0.0)), + segment(0, 2, 0, (0.0, 0.0), (0.0, 0.0)), + ], + ..Network::default() + }; + let path = from_network(&cycle, 2.0, 3.0, 0.5, true).unwrap(); + let elements = path.path().elements().to_vec(); + assert!(matches!(elements.first(), Some(PathEl::MoveTo(p)) if p.x == 0.0 && p.y == 0.0)); + assert!(matches!(elements.get(1), Some(PathEl::LineTo(p)) if p.x == 200.0 && p.y == 0.0)); + let total = 200.0 + 300.0 + 130_000.0_f64.sqrt(); + assert!((path.start - 100.0 / total).abs() < 1e-8); + assert!(matches!(elements.last(), Some(PathEl::ClosePath))); + } + + #[test] + fn rejects_paths_the_native_renderer_cannot_measure() { + let count = 40_000usize; + let many_lines = Network { + vertices: (0..=count).map(|i| vertex(i as f64, 0.0)).collect(), + segments: (0..count).map(|i| segment(if i == 0 { START_SEGMENT_STYLE } else { 0 }, i, i + 1, (0.0, 0.0), (0.0, 0.0))).collect(), + ..Network::default() + }; + assert_eq!(from_network(&many_lines, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer")); + + let huge = Network { + vertices: vec![vertex(0.0, 0.0), vertex(1e9, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + assert_eq!(from_network(&huge, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer")); + + let tiny = Network { + vertices: vec![vertex(0.0, 0.0), vertex(5e-10, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + assert_eq!(from_network(&tiny, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer")); + } +} diff --git a/crates/format/src/fig/text_path_tests.rs b/crates/format/src/fig/text_path_tests.rs new file mode 100644 index 00000000..66148930 --- /dev/null +++ b/crates/format/src/fig/text_path_tests.rs @@ -0,0 +1,211 @@ +//! Mapper → private REST ingress tests for source text-path fields. + +use super::kiwi::Kv; + +const SCHEMA: &str = " +enum NodeType DOCUMENT CANVAS TEXT TEXT_PATH +enum StyleType NONE TEXT +enum TextDecoration NONE UNDERLINE STRIKETHROUGH +struct GUID uint sessionID, uint localID +struct StyleId GUID guid +struct ParentIndex GUID guid, string position +struct Vector float x, float y +struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 +struct FontName string family, string style +message TextOverride uint styleID, FontName fontName, float fontSize +message TextData string characters, uint[] characterStyleIDs, TextOverride[] styleOverrideTable +message TextPathStart float tValue, bool forward +message VectorData uint vectorNetworkBlob, Vector normalizedSize +message Blob byte[] bytes +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, TextData textData, float fontSize, FontName fontName, TextPathStart textPathStart, VectorData vectorData, StyleType styleType, StyleId styleIdForText, TextDecoration textDecoration +message Message NodeChange[] nodeChanges, Blob[] blobs +"; +use figcraft_doc::{NodeKind, TextPath}; + +fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} + +fn a(values: Vec>) -> Kv<'static> { + Kv::Array(values) +} + +fn f(value: f32) -> Kv<'static> { + Kv::Float(value) +} + +fn u(value: u64) -> Kv<'static> { + Kv::Uint(value) +} + +fn s(value: &'static str) -> Kv<'static> { + Kv::Str(value.into()) +} + +fn point(x: f32, y: f32) -> Kv<'static> { + o(vec![("x", f(x)), ("y", f(y))]) +} + +fn guid(id: u64) -> Kv<'static> { + o(vec![("sessionID", u(1)), ("localID", u(id))]) +} + +fn node(id: u64, parent: Option, ty: &'static str, fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut node = vec![("guid", guid(id)), ("type", s(ty))]; + if let Some(parent) = parent { + node.push(("parentIndex", o(vec![("guid", guid(parent)), ("position", s("!"))]))); + } + node.extend(fields); + o(node) +} + +fn blob() -> Vec { + let mut bytes = Vec::new(); + let u32le = |out: &mut Vec, n: u32| out.extend_from_slice(&n.to_le_bytes()); + let f32le = |out: &mut Vec, n: f32| out.extend_from_slice(&n.to_le_bytes()); + u32le(&mut bytes, 2); // vertices + u32le(&mut bytes, 1); // segments + u32le(&mut bytes, 0); // regions + for (x, y) in [(0.0, 0.0), (100.0, 0.0)] { + u32le(&mut bytes, 0); + f32le(&mut bytes, x); + f32le(&mut bytes, y); + } + u32le(&mut bytes, 0x01ff_ffff); // source start-segment marker + u32le(&mut bytes, 0); + f32le(&mut bytes, 0.0); + f32le(&mut bytes, 0.0); + u32le(&mut bytes, 1); + f32le(&mut bytes, 0.0); + f32le(&mut bytes, 0.0); + bytes +} + +fn canvas() -> Vec { + let schema = super::kiwi::Schema::parse(SCHEMA).unwrap(); + let message = message(true); + let message = super::kiwi::encode::message(&schema, schema.find("Message").unwrap(), &message).unwrap(); + let mut out = b"fig-kiwi\0\0\0\0".to_vec(); + for raw in [super::kiwi::encode::schema(&schema), message] { + let mut encoder = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut encoder, &raw).unwrap(); + let compressed = encoder.finish().unwrap(); + out.extend_from_slice(&(compressed.len() as u32).to_le_bytes()); + out.extend_from_slice(&compressed); + } + out +} + +fn message(with_path: bool) -> Kv<'static> { + let mut nodes = vec![node(0, None, "DOCUMENT", vec![]), node(1, Some(0), "CANVAS", vec![])]; + let mut blobs = Vec::new(); + if with_path { + blobs.push(o(vec![("bytes", Kv::Bytes(blob()))])); + let text = o(vec![ + ("characters", s("alpha")), + ("characterStyleIDs", a(vec![u(0), u(0), u(0), u(2), u(2)])), + ( + "styleOverrideTable", + a(vec![o(vec![("styleID", u(2)), ("fontName", o(vec![("family", s("Inter")), ("style", s("Medium"))])), ("fontSize", f(30.0))])]), + ), + ]); + nodes.push(node( + 2, + Some(1), + "TEXT_PATH", + vec![ + ("name", s("alpha path")), + ("size", point(100.0, 0.0)), + ("transform", o(vec![("m00", f(1.0)), ("m01", f(0.0)), ("m02", f(0.0)), ("m10", f(0.0)), ("m11", f(1.0)), ("m12", f(0.0))])), + ("textData", text), + ("fontSize", f(24.0)), + ("fontName", o(vec![("family", s("Inter")), ("style", s("Regular"))])), + ("textPathStart", o(vec![("tValue", f(0.5)), ("forward", Kv::Bool(true))])), + ("vectorData", o(vec![("vectorNetworkBlob", u(0)), ("normalizedSize", point(100.0, 0.0))])), + ], + )); + } else { + nodes.push(node(2, Some(1), "TEXT_PATH", vec![("name", s("broken path")), ("textData", o(vec![("characters", s("fallback"))]))])); + } + o(vec![("nodeChanges", a(nodes)), ("blobs", a(blobs))]) +} + +fn linked_underline_message() -> Kv<'static> { + let Kv::Object(mut root) = message(true) else { panic!("expected message object") }; + let (_, Kv::Array(nodes)) = root.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap() else { panic!("expected node changes array") }; + let text = nodes.iter_mut().find(|n| n.get("name").and_then(Kv::str) == Some("alpha path")).unwrap(); + let Kv::Object(fields) = text else { panic!("expected text node object") }; + fields.push(("styleIdForText", o(vec![("guid", guid(3))]))); + nodes.push(node( + 3, + Some(1), + "TEXT", + vec![ + ("name", s("Shared style")), + ("styleType", s("TEXT")), + ("fontName", o(vec![("family", s("Inter")), ("style", s("Regular"))])), + ("fontSize", f(24.0)), + ("textDecoration", s("UNDERLINE")), + ], + )); + Kv::Object(root) +} + +#[test] +fn mapped_text_path_reaches_native_model_with_runs_and_no_standin_warning() { + let message = message(true); + let (json, warnings) = super::map::to_rest(&message, "Text path"); + assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}"); + let (doc, warnings) = crate::rest::import_fig(&json, &|_| None).unwrap(); + assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}"); + let node = doc.nodes().find(|n| n.name == "alpha path").unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("expected text") }; + let path = text.path.as_deref().unwrap(); + assert_eq!(path, &TextPath { d: "M0,0 L100,0".into(), start: 0.5, flip: false }); + assert_eq!(text.characters, "alpha"); + assert_eq!(text.runs.len(), 1); + assert_eq!(text.runs[0].start, 3); + assert_eq!(text.runs[0].end, 5); +} + +#[test] +fn public_rest_import_ignores_private_fig_text_path_field() { + let message = message(true); + let (json, _) = super::map::to_rest(&message, "Text path"); + // The mapper's private field is accepted only by import_fig, never by public REST import. + let path = json["document"]["children"][0]["children"][0]["_figTextPath"].clone(); + assert!(path.is_object()); + let (doc, _) = crate::rest::import(&json, &|_| None).unwrap(); + let node = doc.nodes().find(|n| n.name == "alpha path").unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("expected text") }; + assert!(text.path.is_none()); +} + +#[test] +fn serialized_kiwi_canvas_import_decodes_start_and_builds_native_path() { + let (doc, warnings) = super::import(&canvas(), "Serialized text path").unwrap(); + let node = doc.nodes().find(|n| n.name == "alpha path").unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("expected text") }; + let path = text.path.as_deref().unwrap(); + assert_eq!(path, &TextPath { d: "M0,0 L100,0".into(), start: 0.5, flip: false }); + assert_eq!(text.characters, "alpha"); + assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}"); +} + +#[test] +fn malformed_text_path_falls_back_with_specific_warning() { + let message = message(false); + let (json, warnings) = super::map::to_rest(&message, "Text path"); + assert!(warnings.iter().any(|w| w.contains("text path has no start data") && w.contains("text imports off its path")), "{warnings:?}"); + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + let node = doc.nodes().find(|n| n.name == "broken path").unwrap(); + assert!(matches!(&node.kind, NodeKind::Text(text) if text.path.is_none())); +} + +#[test] +fn linked_text_style_decoration_warns_from_effective_mapped_style() { + let message = linked_underline_message(); + let (json, warnings) = super::map::to_rest(&message, "Text path"); + assert_eq!(json["document"]["children"][0]["children"][0]["style"]["textDecoration"], "UNDERLINE"); + assert!(warnings.iter().any(|w| w == "text path underline or strikethrough decoration is not rendered"), "{warnings:?}"); +} diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index 3875204f..9b8e9be4 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; use figcraft_doc::{ DecorationLength, DecorationStyle, LetterSpacing, LineHeight, ListStyle, ParaList, StyleRun, TextAlignH, TextAlignV, TextAutoResize, TextCase, - TextData, TextDecoration, TextStyle, + TextData, TextDecoration, TextPath, TextStyle, }; use serde_json::Value; @@ -29,6 +29,16 @@ pub(super) fn text_data(cx: &mut Ctx, v: &Value) -> TextData { td.runs = runs(cx, v, &td); td.normalize(); lists(v, &mut td); + if cx.fig_sources.is_some() + && let Some(path) = v.get("_figTextPath") + { + match (path.get("d").and_then(Value::as_str), path.get("start").and_then(Value::as_f64), path.get("flip").and_then(Value::as_bool)) { + (Some(d), Some(start), Some(flip)) if !d.is_empty() && start.is_finite() && (0.0..=1.0).contains(&start) => { + td.path = Some(Box::new(TextPath { d: d.to_owned(), start, flip })); + } + _ => cx.warn("invalid internal .fig text path data was ignored"), + } + } td } diff --git a/docs/gaps.md b/docs/gaps.md index e8b111a8..a5cd6a3b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -41,6 +41,10 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe ## Entries ### 1. `.fig` files: the import's tail +- **Text paths (2026-10-15):** local `.fig` import maps supported connected `TEXT_PATH` networks + to existing editable native text paths, preserving the start segment, direction and styled + runs. Other vertical placements, multiline/ambiguous networks, path decorations, public REST + text paths and `.fig` text-path export remain limited. - **Landed (2026-10-11):** `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors, auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning (`crates/format/src/fig`). Then (2026-10-11): vectors open as editable vector networks (per-point corner radius, handle mirroring, end caps; outlines when the network blob is missing or damaged), sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning, and set-variable, set-variable-mode and conditional prototype actions come across. `file.open`, `file.import`, drag and drop, the CLI and MCP's `open_file` read `.fig`; the file is never saved over. From a96222012b437ae7f47403dcd3fd514d50eb73e1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:51:00 +0700 Subject: [PATCH 077/118] #619: leave progress docs to the maintainer's docs PR --- docs/file-format-parity.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c25e33ea..d8e51711 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -60,13 +60,6 @@ export limit, not a native document nesting limit. ### Export to `.fig` (experimental) -File ▸ Save as offers `.figcraft` and `.fig` as separate file types in the system dialog. A `.fig` -destination writes the same experimental export copy of the document that is shown (`file.save` and -`file.saveAs` with a path ending in `.fig` do the same) and reports what was left out. The file's own -path, its unsaved changes, versions, branches and undo history are not touched, and a shown earlier -version stays shown. Other apps may not open every exported `.fig` file. A plain Save never writes over -a file whose saved path ends in `.fig`; it asks for a destination instead. - `file.export {format: "fig"}` (or a `path` ending in `.fig`), File ▸ Export… ▸ .fig (experimental), `figcraft-cli export -o .fig` (and `convert`) and MCP's `export {format: "fig"}` write the whole document. The writer (`crates/format/src/fig/export.rs`) works from the same public format From 6baeb53b200ba6c6ec8f5ca39bee4799e0346904 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:51:01 +0700 Subject: [PATCH 078/118] #619: the Save as messages in every catalog --- crates/ui-egui/locales/cs.toml | 2 ++ crates/ui-egui/locales/es.toml | 2 +- crates/ui-egui/locales/fr.toml | 2 ++ crates/ui-egui/locales/id.toml | 2 ++ crates/ui-egui/locales/ja.toml | 2 ++ crates/ui-egui/locales/ko.toml | 2 ++ crates/ui-egui/locales/pt-BR.toml | 2 ++ crates/ui-egui/locales/ru.toml | 2 ++ crates/ui-egui/locales/sv.toml | 2 ++ crates/ui-egui/locales/tr.toml | 2 ++ crates/ui-egui/locales/zh-Hans.toml | 2 ++ 11 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 268ff527..e8fc0330 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -13,6 +13,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, commity: {commits}, +{added} / −{deleted} řádků (Δ {delta}), +{bin_added} / −{bin_deleted} binárních souborů, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Kolekce tohoto souboru s těmito režimy a proměnnými: přepište jejich hodnoty pro jednotlivé režimy a přidejte vlastní proměnné" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Soubor .figcraft s tímto názvem už existuje. Nahrazení potvrdíte výběrem jeho úplného názvu." "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Samostatná vrstva se zarovná ke svému rámci: vyberte více vrstev nebo jednu v rámci" "A responsive product page: hero, features, figures, a quote and a footer" = "Responzivní produktová stránka: úvodní blok, funkce, čísla, citace a zápatí" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Pružina se pohybuje až do ustálení: délku určuje její hmotnost, tuhost a tlumení" @@ -1393,6 +1394,7 @@ status = "draft — needs native review" "Use as mask" = "Použít jako masku" "Use auto layout" = "Použít automatické rozvržení" "Use number keys for opacity" = "Nastavovat krytí číselnými klávesami" +"Use the .figcraft or .fig extension." = "Použijte příponu .figcraft nebo .fig." "Use the layer's fill" = "Použít výplň vrstvy" "Used in {files} · {instances}" = "Použití – {files} · {instances}" "Username" = "Uživatelské jméno" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 0f2e3391..47000119 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -9,7 +9,6 @@ status = "draft — needs native review" [ui] "Couldn't download the variables" = "No se pudieron descargar las variables" -"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Ya existe un archivo .figcraft con este nombre. Elige su nombre completo para confirmar que deseas reemplazarlo." "% of all commits" = "% del total de commits" "(no longer published)" = "(ya no publicado)" @@ -19,6 +18,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commits, +{added} / −{deleted} líneas (Δ {delta}), +{bin_added} / −{bin_deleted} recursos binarios, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Una colección de este archivo con estos modos y variables: modifica sus valores por modo y añade variables propias" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Ya existe un archivo .figcraft con este nombre. Elige su nombre completo para confirmar que deseas reemplazarlo." "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Una capa sola se alinea con su marco: selecciona más capas o una dentro de un marco" "A responsive product page: hero, features, figures, a quote and a footer" = "Una página de producto adaptable: cabecera, funciones, cifras, una cita y un pie" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Un muelle se reproduce hasta que se asienta: su masa, rigidez y amortiguación fijan cuánto dura" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 7fafd3c9..3f2e12be 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -17,6 +17,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login} : {prs} PR, {commits} commits, +{added} / −{deleted} lignes (Δ {delta}), +{bin_added} / −{bin_deleted} ressources binaires, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Une collection de ce fichier avec ces modes et ces variables : remplacez leurs valeurs par mode et ajoutez vos propres variables" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Un fichier .figcraft portant ce nom existe déjà. Choisissez son nom complet pour confirmer le remplacement." "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Un calque seul s’aligne sur son cadre : sélectionnez d’autres calques, ou un calque dans un cadre" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Un ressort joue jusqu’à ce qu’il se stabilise : sa masse, sa rigidité et son amortissement en fixent la durée" "About" = "À propos" @@ -1338,6 +1339,7 @@ status = "draft — needs native review" "Use as mask" = "Utiliser comme masque" "Use auto layout" = "Utiliser la mise en page auto" "Use number keys for opacity" = "Utiliser les touches numériques pour l’opacité" +"Use the .figcraft or .fig extension." = "Utilisez l’extension .figcraft ou .fig." "Use the layer's fill" = "Utiliser le remplissage du calque" "Used in {files} · {instances}" = "Utilisé dans {files} · {instances}" "Username" = "Nom d’utilisateur" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 5de7634d..c56d9fc7 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -17,6 +17,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commit, +{added} / −{deleted} baris (Δ {delta}), +{bin_added} / −{bin_deleted} aset biner, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Koleksi file ini dengan mode dan variabel tersebut: timpa nilainya per mode dan tambahkan variabel sendiri" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "File .figcraft dengan nama ini sudah ada. Pilih nama lengkapnya untuk mengonfirmasi penggantian." "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Satu layer saja diratakan ke frame-nya: pilih lebih banyak layer, atau satu layer di dalam frame" "A responsive product page: hero, features, figures, a quote and a footer" = "Halaman produk responsif: hero, fitur, angka, kutipan, dan footer" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Spring berjalan hingga stabil: massa, kekakuan, dan redamannya menentukan durasinya" @@ -1397,6 +1398,7 @@ status = "draft — needs native review" "Use as mask" = "Gunakan sebagai mask" "Use auto layout" = "Gunakan auto layout" "Use number keys for opacity" = "Gunakan tombol angka untuk opasitas" +"Use the .figcraft or .fig extension." = "Gunakan ekstensi .figcraft atau .fig." "Use the layer's fill" = "Gunakan fill layer" "Used in {files} · {instances}" = "Digunakan di {files} · {instances}" "Username" = "Nama pengguna" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 4d4d6d34..c04e07ca 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -19,6 +19,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}:PR {prs} 件、コミット {commits} 件、+{added} / −{deleted} 行(Δ {delta})、バイナリアセット +{bin_added} / −{bin_deleted}、{first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "このファイルのコレクションとそのモード・変数:モードごとに値を上書きし、独自の変数を追加できる" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "この名前の .figcraft ファイルは既に存在します。置き換えるには、拡張子まで含む名前を選択。" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "単独のレイヤーはフレームに整列します:レイヤーを追加で選択するか、フレーム内のレイヤーを選択" "A responsive product page: hero, features, figures, a quote and a footer" = "レスポンシブな製品ページ:ヒーロー、機能、数値、引用、フッター" "A spring plays until it settles: its mass, stiffness and damping set how long" = "スプリングは静止するまで再生。長さは質量・剛性・減衰で決まる" @@ -1406,6 +1407,7 @@ status = "draft — needs native review" "Use as mask" = "マスクとして使用" "Use auto layout" = "オートレイアウトを使用" "Use number keys for opacity" = "数字キーで不透明度を変更" +"Use the .figcraft or .fig extension." = "拡張子は .figcraft または .fig を使用。" "Use the layer's fill" = "レイヤーの塗りを使用" "Used in {files} · {instances}" = "{files}で使用 · {instances}" "Username" = "ユーザー名" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index ec732905..c57e4279 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -18,6 +18,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR {prs}개, 커밋 {commits}개, +{added} / −{deleted}줄 (Δ {delta}), 바이너리 에셋 +{bin_added} / −{bin_deleted}, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "이 파일의 컬렉션과 그 모드 및 변수: 모드별로 값을 재정의하고 자체 변수를 추가할 수 있음" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "이 이름의 .figcraft 파일이 이미 있습니다. 바꾸려면 확장자까지 포함한 전체 이름을 선택하세요." "A layer on its own aligns to its frame: select more layers, or one in a frame" = "레이어 하나는 프레임에 맞춰 정렬됩니다: 레이어를 더 선택하거나 프레임 안의 레이어를 선택하세요" "A spring plays until it settles: its mass, stiffness and damping set how long" = "스프링은 멈출 때까지 재생됨. 길이는 질량, 강성, 감쇠로 결정됨" "About" = "정보" @@ -1338,6 +1339,7 @@ status = "draft — needs native review" "Use as mask" = "마스크로 사용" "Use auto layout" = "자동 레이아웃 사용" "Use number keys for opacity" = "숫자 키로 불투명도 변경" +"Use the .figcraft or .fig extension." = ".figcraft 또는 .fig 확장자를 사용하세요." "Use the layer's fill" = "레이어의 채우기 사용" "Used in {files} · {instances}" = "{files}에서 사용 · {instances}" "Username" = "사용자 이름" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index c2189de8..0bd03b84 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -16,6 +16,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} linhas (Δ {delta}), +{bin_added} / −{bin_deleted} arquivos binários, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Uma coleção deste arquivo com estes modos e variáveis: substitua os valores por modo e adicione variáveis próprias" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Já existe um arquivo .figcraft com este nome. Escolha o nome completo dele para confirmar a substituição." "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Uma camada sozinha se alinha ao seu quadro: selecione mais camadas ou uma dentro de um quadro" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Uma mola se move até se estabilizar: sua massa, rigidez e amortecimento definem a duração" "About" = "Sobre" @@ -1337,6 +1338,7 @@ status = "draft — needs native review" "Use as mask" = "Usar como máscara" "Use auto layout" = "Usar layout automático" "Use number keys for opacity" = "Usar teclas numéricas para a opacidade" +"Use the .figcraft or .fig extension." = "Use a extensão .figcraft ou .fig." "Use the layer's fill" = "Usar o preenchimento da camada" "Used in {files} · {instances}" = "Usado em {files} · {instances}" "Username" = "Nome de usuário" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 3ab40757..2725a7ab 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -15,6 +15,7 @@ name = "Русский" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, коммитов: {commits}, +{added} / −{deleted} строк (Δ {delta}), +{bin_added} / −{bin_deleted} бинарных ресурсов, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Коллекция этого файла с этими режимами и переменными: переопределяйте значения для каждого режима и добавляйте собственные переменные" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Файл .figcraft с таким именем уже существует. Чтобы подтвердить замену, выберите его полное имя." "A responsive product page: hero, features, figures, a quote and a footer" = "Адаптивная страница продукта: главный блок, возможности, показатели, цитата и подвал" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Пружинная анимация длится до затухания: её время задают масса, жёсткость и демпфирование" "A title, an agenda, a big figure, a chart and a closing slide" = "Заголовок, повестка, крупный показатель, диаграмма и заключительный слайд" @@ -1394,6 +1395,7 @@ name = "Русский" "Use as mask" = "Использовать как маску" "Use auto layout" = "Использовать автолейаут" "Use number keys for opacity" = "Задавать непрозрачность цифровыми клавишами" +"Use the .figcraft or .fig extension." = "Используйте расширение .figcraft или .fig." "Use the layer's fill" = "Использовать заливку слоя" "Used in {files} · {instances}" = "Используется в {files} · {instances}" "Username" = "Имя пользователя" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index f7b2999d..b2d7b7a5 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -16,6 +16,7 @@ name = "Svenska" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR:er, {commits} commits, +{added} / −{deleted} rader (Δ {delta}), +{bin_added} / −{bin_deleted} binära tillgångar, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "En samling i den här filen med dessa lägen och variabler: åsidosätt deras värden per läge och lägg till egna variabler" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Det finns redan en .figcraft-fil med det här namnet. Välj dess fullständiga namn för att bekräfta att den ersätts." "A responsive product page: hero, features, figures, a quote and a footer" = "En responsiv produktsida: toppsektion, funktioner, figurer, ett citat och en sidfot" "A spring plays until it settles: its mass, stiffness and damping set how long" = "En fjäder spelas tills den stannar: dess massa, styvhet och dämpning avgör hur länge" "A title, an agenda, a big figure, a chart and a closing slide" = "En titel, en agenda, en stor figur, ett diagram och en avslutande bild" @@ -1395,6 +1396,7 @@ name = "Svenska" "Use as mask" = "Använd som mask" "Use auto layout" = "Använd automatisk layout" "Use number keys for opacity" = "Använd siffertangenterna för opacitet" +"Use the .figcraft or .fig extension." = "Använd filändelsen .figcraft eller .fig." "Use the layer's fill" = "Använd lagrets fyllning" "Used in {files} · {instances}" = "Används i {files} · {instances}" "Username" = "Användarnamn" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index a2be6b99..b6021987 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -18,6 +18,7 @@ name = "Türkçe" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commit, +{added} / −{deleted} satır (Δ {delta}), +{bin_added} / −{bin_deleted} ikili varlık, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Bu dosyanın bu modları ve değişkenleri içeren bir koleksiyonu: değerlerini mod başına geçersiz kılın ve kendi değişkenlerinizi ekleyin" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Bu adda bir .figcraft dosyası zaten var. Değiştirmeyi onaylamak için tam adını seçin." "A responsive product page: hero, features, figures, a quote and a footer" = "Duyarlı bir ürün sayfası: giriş bölümü, özellikler, rakamlar, bir alıntı ve bir alt bilgi" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Yay, durulana kadar oynar: kütlesi, sertliği ve sönümü ne kadar süreceğini belirler" "A title, an agenda, a big figure, a chart and a closing slide" = "Bir başlık, bir gündem, büyük bir rakam, bir grafik ve bir kapanış slaydı" @@ -1397,6 +1398,7 @@ name = "Türkçe" "Use as mask" = "Maske olarak kullan" "Use auto layout" = "Otomatik yerleşim kullan" "Use number keys for opacity" = "Opaklık için rakam tuşlarını kullan" +"Use the .figcraft or .fig extension." = ".figcraft veya .fig uzantısını kullanın." "Use the layer's fill" = "Katmanın dolgusunu kullan" "Used in {files} · {instances}" = "Kullanıldığı yer: {files} · {instances}" "Username" = "Kullanıcı adı" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index af4cf653..3bf2ee9b 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -19,6 +19,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}:{prs} 个 PR,{commits} 次提交,+{added} / −{deleted} 行(Δ {delta}),二进制资源 +{bin_added} / −{bin_deleted},{first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "本文件中带有这些模式和变量的集合:可按模式覆盖其值,并添加自己的变量" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "已存在同名的 .figcraft 文件。请选择其完整名称以确认替换。" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "单个图层与其所在画框对齐:请再选择其他图层,或选择画框内的图层" "A spring plays until it settles: its mass, stiffness and damping set how long" = "弹簧动画播放至静止,时长由质量、刚度和阻尼决定" "About" = "关于" @@ -1339,6 +1340,7 @@ status = "draft — needs native review" "Use as mask" = "用作蒙版" "Use auto layout" = "使用自动布局" "Use number keys for opacity" = "用数字键设置不透明度" +"Use the .figcraft or .fig extension." = "请使用 .figcraft 或 .fig 扩展名。" "Use the layer's fill" = "使用图层的填充" "Used in {files} · {instances}" = "用于 {files} · {instances}" "Username" = "用户名" From 3fadbf4ba9613f1fcc9f11c770f927c5088524f4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:54:25 +0700 Subject: [PATCH 079/118] #632: read the network with the decoder #631 left --- crates/format/src/fig/map.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 9a2b696d..9e388cc4 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1186,7 +1186,7 @@ impl<'a> Mapper<'a> { fail(self, "has no vector network"); return; }; - let Some((net, _)) = network::decode_styled(blob) else { + let Some(net) = network::decode(blob) else { fail(self, "has damaged vector data"); return; }; From ca94b5fe6f144b31092731d056fb4046c450476c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:54:27 +0700 Subject: [PATCH 080/118] #632: leave the progress docs to the maintainer --- docs/gaps.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index a5cd6a3b..e8b111a8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -41,10 +41,6 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe ## Entries ### 1. `.fig` files: the import's tail -- **Text paths (2026-10-15):** local `.fig` import maps supported connected `TEXT_PATH` networks - to existing editable native text paths, preserving the start segment, direction and styled - runs. Other vertical placements, multiline/ambiguous networks, path decorations, public REST - text paths and `.fig` text-path export remain limited. - **Landed (2026-10-11):** `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors, auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning (`crates/format/src/fig`). Then (2026-10-11): vectors open as editable vector networks (per-point corner radius, handle mirroring, end caps; outlines when the network blob is missing or damaged), sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning, and set-variable, set-variable-mode and conditional prototype actions come across. `file.open`, `file.import`, drag and drop, the CLI and MCP's `open_file` read `.fig`; the file is never saved over. From d84aa7f1e1f14a41fc2d0591064a8a220616d245 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:38:33 +0200 Subject: [PATCH 081/118] M1: import image adjustments from .fig files Image paints that carry a current filter container now import their exposure, contrast, vibrance (as saturation), temperature, tint, highlights and shadows as native image filters. Neutral values leave the paint untouched. The older color-adjust container alone, both containers together, filters that have no native equivalent (brightness, detail, vignette and unknown fields) and malformed or non-finite values are skipped and reported as import notes. A positive tint in .fig data moves toward magenta, so it is flipped once at the .fig boundary; public REST image filters keep their sign and are not reported as approximated. Imported filters use the approximate native renderer, which the import says once. --- crates/format/src/fig/image_filter_tests.rs | 83 +++++++++++++++++++ crates/format/src/fig/map.rs | 74 +++++++++++++++-- .../format/src/fig/map_image_filter_tests.rs | 27 ++++++ crates/format/src/fig/tests.rs | 3 + crates/format/src/rest/props.rs | 23 ++++- crates/format/src/rest/tests.rs | 18 ++++ 6 files changed, 219 insertions(+), 9 deletions(-) create mode 100644 crates/format/src/fig/image_filter_tests.rs create mode 100644 crates/format/src/fig/map_image_filter_tests.rs diff --git a/crates/format/src/fig/image_filter_tests.rs b/crates/format/src/fig/image_filter_tests.rs new file mode 100644 index 00000000..b8d64694 --- /dev/null +++ b/crates/format/src/fig/image_filter_tests.rs @@ -0,0 +1,83 @@ +//! Image paint filters read from `.fig` files, on fixtures built in code with the parent module's +//! helpers. + +use super::*; + +/// The shared schema plus the image filter containers only these tests use. +fn image_filter_schema() -> Schema { + let text = SCHEMA + .replace( + "message Paint ", + "message FilterColorAdjust float tint, float shadows, float highlights, float detail, float exposure, float vignette, float temperature, float vibrance\n\ + message PaintFilterMessage float tint, float shadows, float highlights, float detail, float exposure, float vignette, float temperature, float vibrance, float contrast, float brightness, float mystery\n\ + message Paint ", + ) + .replace("ImageScaleMode imageScaleMode\n", "ImageScaleMode imageScaleMode, PaintFilterMessage paintFilter, FilterColorAdjust filterColorAdjust\n"); + Schema::parse(&text).unwrap() +} + +#[test] +fn image_filters_import_from_current_container_and_skip_ambiguous_data() { + let filter = |fields: Vec<(&'static str, Kv<'static>)>| o(fields); + let image_paint = |id: u64, name: &'static str, mut extra: Vec<(&'static str, Kv<'static>)>| { + let mut paint = vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(HASH.to_vec()))]))]; + paint.append(&mut extra); + node(id, Some((1, "!")), "RECTANGLE", name, vec![("size", xy(2.0, 1.0)), ("fillPaints", arr(vec![o(paint)]))]) + }; + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + image_paint( + 2, + "Current", + vec![( + "paintFilter", + filter(vec![ + ("exposure", f(0.25)), + ("contrast", f(0.3)), + ("vibrance", f(-0.4)), + ("temperature", f(0.5)), + ("tint", f(1.0)), + ("highlights", f(-0.6)), + ("shadows", f(0.7)), + ]), + )], + ), + image_paint(3, "Negative tint", vec![("paintFilter", filter(vec![("tint", f(-1.0)), ("contrast", f(-0.3))]))]), + image_paint(4, "Neutral", vec![]), + image_paint(5, "Explicit neutral", vec![("paintFilter", filter(vec![("exposure", f(0.0)), ("vibrance", f(0.0))]))]), + image_paint(6, "Legacy", vec![("filterColorAdjust", filter(vec![("exposure", f(0.8))]))]), + image_paint(7, "Both", vec![("filterColorAdjust", filter(vec![("exposure", f(0.8))])), ("paintFilter", filter(vec![("contrast", f(0.2))]))]), + image_paint( + 8, + "Unsupported", + vec![("paintFilter", filter(vec![("brightness", f(0.4)), ("detail", f(-0.2)), ("vignette", f(0.3)), ("mystery", f(0.1))]))], + ), + ]; + let bytes = zip_file(&canvas(&image_filter_schema(), &o(vec![("nodeChanges", arr(nodes))]))); + let (doc, warnings) = import(&bytes, "ImageFilters.fig").unwrap(); + let get = |name: &str| { + let node = doc.nodes().find(|node| node.name == name).unwrap(); + let figcraft_doc::color::PaintKind::Image(image) = &node.fills[0].kind else { panic!("{name} is not an image fill") }; + image + }; + let current = get("Current"); + assert!((current.filters.exposure - 0.25).abs() < 1e-6); + assert!((current.filters.contrast - 0.3).abs() < 1e-6, "observed raw contrast remains 0.3"); + assert!((current.filters.saturation + 0.4).abs() < 1e-6, "vibrance maps to saturation"); + assert!((current.filters.temperature - 0.5).abs() < 1e-6); + assert!((current.filters.tint + 1.0).abs() < 1e-6, "positive Figma tint converts once at the REST boundary"); + assert!((current.filters.highlights + 0.6).abs() < 1e-6); + assert!((current.filters.shadows - 0.7).abs() < 1e-6); + assert!((get("Negative tint").filters.tint - 1.0).abs() < 1e-6); + assert!((get("Negative tint").filters.contrast + 0.3).abs() < 1e-6); + assert_eq!(get("Neutral").filters, Default::default()); + assert_eq!(get("Explicit neutral").filters, Default::default()); + assert_eq!(doc.images.get(¤t.image).map(|image| image.bytes.as_slice()), Some(png().as_slice())); + assert!(warnings.iter().any(|w| w.contains("legacy image filter container is unsupported")), "{warnings:?}"); + assert!(warnings.iter().any(|w| w.contains("both filter containers are present")), "{warnings:?}"); + for field in ["brightness", "detail", "vignette", "mystery"] { + assert!(warnings.iter().any(|w| w.contains(field)), "missing {field} warning: {warnings:?}"); + } + assert_eq!(warnings.iter().filter(|w| w.contains("approximate renderer")).count(), 1, "deduplicated approximation warning: {warnings:?}"); +} diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 0fe44662..62d10d71 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -55,6 +55,10 @@ pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec) { (out, m.warnings) } +#[cfg(test)] +#[path = "map_image_filter_tests.rs"] +mod image_filter_tests; + /// REST importer warnings reworded for a `.fig` import (`None`: drop it). pub(super) fn adapt_warning(w: String) -> Option { if w.contains("geometry=paths") { @@ -73,6 +77,16 @@ fn num(n: &Kv, k: &str) -> Option { n.get(k).and_then(Kv::f64) } +fn filter_number(value: &Kv) -> Option { + let number = match value { + Kv::Float(value) => f64::from(*value), + Kv::Int(value) => *value as f64, + Kv::Uint(value) => *value as f64, + _ => return None, + }; + number.is_finite().then_some(number) +} + fn guid(v: &Kv) -> Option { Some(format!("{}:{}", num(v, "sessionID")? as u64, num(v, "localID")? as u64)) } @@ -964,11 +978,11 @@ impl<'a> Mapper<'a> { let fill_style = self.style(v, "styleIdForFill", "inheritFillStyleID", "fill", &mut styles); let fills = own("fillPaints") .unwrap_or_else(|| fill_style.map_or_else(|| v.list("fillPaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default())); - o.insert("fills".into(), Value::Array(fills.iter().filter_map(paint).collect())); + o.insert("fills".into(), Value::Array(fills.iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect())); let stroke_style = self.style(v, "styleIdForStrokeFill", "inheritFillStyleIDForStroke", "stroke", &mut styles); let strokes = own("strokePaints") .unwrap_or_else(|| stroke_style.map_or_else(|| v.list("strokePaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default())); - o.insert("strokes".into(), Value::Array(strokes.iter().filter_map(paint).collect())); + o.insert("strokes".into(), Value::Array(strokes.iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect())); let effect_style = self.style(v, "styleIdForEffect", "inheritEffectStyleID", "effect", &mut styles); let effects = effect_style.map_or_else(|| v.list("effects"), |s| s.get("effects").map(Kv::list).unwrap_or_default()); if !effects.is_empty() { @@ -1138,7 +1152,7 @@ impl<'a> Mapper<'a> { } o.insert("style".into(), Value::Object(style)); let Some(td) = td else { return }; - char_styles(o, td, size); + char_styles(o, td, size, &mut |w| self.warn(w)); let lines: Vec<&str> = td .get("lines") .map(Kv::list) @@ -1333,7 +1347,7 @@ impl<'a> Mapper<'a> { o.remove("styleOverrideTable"); if let Some(tv) = val.get("textValue") { let size = o.get("style").and_then(|s| s.get("fontSize")).and_then(Value::as_f64); - char_styles(o, tv, size); + char_styles(o, tv, size, &mut |w| self.warn(w)); } } } @@ -1920,7 +1934,7 @@ fn paint_bound(p: &Kv) -> bool { alias(p.get("colorVar")).is_some() || p.get("stopsVar").map(Kv::list).unwrap_or_default().iter().any(|s| alias(s.get("colorVar")).is_some()) } -fn paint(p: &Kv) -> Option { +fn paint(p: &Kv, warn: &mut dyn FnMut(String)) -> Option { let ty = p.get("type").and_then(Kv::str)?; let mut o = Obj::new(); o.insert("type".into(), json!(ty)); @@ -1989,6 +2003,52 @@ fn paint(p: &Kv) -> Option { if let Some(r) = num(p, "rotation") { o.insert("rotation".into(), json!(r)); } + match (p.get("filterColorAdjust"), p.get("paintFilter")) { + (Some(_), Some(_)) => { + warn("image filters are ambiguous because both filter containers are present; filter conversion was skipped".into()) + } + (Some(_), None) => warn("legacy image filter container is unsupported; filter conversion was skipped".into()), + (None, Some(filters)) => { + let mut mapped = Obj::new(); + let fields = [ + ("exposure", "exposure"), + ("contrast", "contrast"), + ("vibrance", "saturation"), + ("temperature", "temperature"), + ("tint", "tint"), + ("highlights", "highlights"), + ("shadows", "shadows"), + ]; + if let Kv::Object(values) = filters { + for (source, target) in fields { + if let Some(value) = filters.get(source) { + match filter_number(value) { + Some(value) => { + mapped.insert(target.into(), json!(value)); + } + None => warn(format!("malformed image filter `{source}` was skipped")), + } + } + } + for (name, value) in values { + if fields.iter().any(|(known, _)| *known == *name) { + continue; + } + match filter_number(value) { + Some(0.0) => {} + Some(_) => warn(format!("nonzero unsupported image filter `{}` was skipped", crate::rest::props::clip_str(name))), + None => warn(format!("malformed unsupported image filter `{}` was skipped", crate::rest::props::clip_str(name))), + } + } + } else { + warn("malformed image paintFilter container was skipped".into()); + } + if !mapped.is_empty() { + o.insert("filters".into(), Value::Object(mapped)); + } + } + (None, None) => {} + } } _ => {} } @@ -2046,7 +2106,7 @@ fn variable_value(d: &Kv) -> Option { /// Per-character styles of text data `td` (`characterStyleIDs` + `styleOverrideTable`) as REST /// `characterStyleOverrides` + `styleOverrideTable`; `size`: the base font size. -fn char_styles(o: &mut Obj, td: &Kv, size: Option) { +fn char_styles(o: &mut Obj, td: &Kv, size: Option, warn: &mut dyn FnMut(String)) { let ids: Vec = td.get("characterStyleIDs").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).map(|i| i as u64).collect(); if ids.iter().all(|i| *i == 0) { return; @@ -2056,7 +2116,7 @@ fn char_styles(o: &mut Obj, td: &Kv, size: Option) { let Some(sid) = num(e, "styleID") else { continue }; let mut s = text_style(&View::of(e), size); if let Some(fills) = e.get("fillPaints") { - s.insert("fills".into(), Value::Array(fills.list().iter().filter_map(paint).collect())); + s.insert("fills".into(), Value::Array(fills.list().iter().filter_map(|p| paint(p, warn)).collect())); } table.insert((sid as u64).to_string(), Value::Object(s)); } diff --git a/crates/format/src/fig/map_image_filter_tests.rs b/crates/format/src/fig/map_image_filter_tests.rs new file mode 100644 index 00000000..a8db39a7 --- /dev/null +++ b/crates/format/src/fig/map_image_filter_tests.rs @@ -0,0 +1,27 @@ +//! Malformed image paint filters are skipped with a warning. + +use super::*; + +#[test] +fn malformed_non_finite_filter_is_skipped_with_warning() { + let paint = Kv::Object(vec![("type", Kv::Enum("IMAGE")), ("paintFilter", Kv::Object(vec![("exposure", Kv::Float(f32::NAN))]))]); + let mut warnings = Vec::new(); + let value = super::paint(&paint, &mut |warning| warnings.push(warning)).unwrap(); + assert!(value.get("filters").is_none(), "{value}"); + assert!(warnings.iter().any(|warning| warning.contains("malformed image filter `exposure`")), "{warnings:?}"); +} + +#[test] +fn boolean_filter_values_are_malformed_even_when_false() { + for (field, value, expected_warning) in [ + ("exposure", Kv::Bool(true), "malformed image filter `exposure`"), + ("exposure", Kv::Bool(false), "malformed image filter `exposure`"), + ("mystery", Kv::Bool(false), "malformed unsupported image filter `mystery`"), + ] { + let paint = Kv::Object(vec![("type", Kv::Enum("IMAGE")), ("paintFilter", Kv::Object(vec![(field, value)]))]); + let mut warnings = Vec::new(); + let value = super::paint(&paint, &mut |warning| warnings.push(warning)).unwrap(); + assert!(value.get("filters").is_none(), "{value}"); + assert!(warnings.iter().any(|warning| warning.contains(expected_warning)), "{warnings:?}"); + } +} diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 19c4fc15..e98a1f34 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -873,6 +873,9 @@ fn nested_instances_resized_again_drop_the_layout_derived_for_their_old_size() { #[path = "tail_tests.rs"] mod tail; +#[path = "image_filter_tests.rs"] +mod image_filters; + /// `.fig` files in the folder `FIGCRAFT_FIG_SAMPLES` names (none when it's unset). pub(super) fn samples() -> Vec { let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return Vec::new() }; diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index 67ec8063..ac4d3519 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -178,6 +178,24 @@ fn image(cx: &mut Ctx, p: &Value) -> ImagePaint { .filter(|c| c[0].abs() > 1e-9 && c[3].abs() > 1e-9); let f = p.get("filters"); let filter = |k: &str| f.and_then(|f| num(f, k)).unwrap_or(0.0).clamp(-1.0, 1.0) as f32; + let filter_fields = ["exposure", "contrast", "saturation", "temperature", "tint", "highlights", "shadows"]; + let mut has_filters = false; + if let Some(filters) = f.and_then(Value::as_object) { + for (key, value) in filters { + match value.as_f64().filter(|value| value.is_finite()) { + Some(value) if filter_fields.contains(&key.as_str()) => has_filters |= value != 0.0, + Some(0.0) => {} + Some(_) => cx.warn(format!("nonzero unsupported image filter `{}` was skipped", clip_str(key))), + None => cx.warn(format!("malformed image filter `{}` was skipped", clip_str(key))), + } + } + } else if f.is_some_and(|filters| !filters.is_null()) { + cx.warn("malformed image filters container was skipped"); + } + let fig = cx.fig_sources.is_some(); + if has_filters && fig { + cx.warn("image filters use FigCraft's approximate renderer"); + } ImagePaint { image: cx.image_key(text(p, "imageRef").unwrap_or("")), scale_mode, @@ -189,7 +207,8 @@ fn image(cx: &mut Ctx, p: &Value) -> ImagePaint { contrast: filter("contrast"), saturation: filter("saturation"), temperature: filter("temperature"), - tint: filter("tint"), + // A positive tint in .fig sources moves toward magenta; native positive tint adds green. + tint: if fig { -filter("tint") } else { filter("tint") }, highlights: filter("highlights"), shadows: filter("shadows"), }, @@ -550,6 +569,6 @@ pub(super) fn export_settings(v: &Value) -> Vec { } /// At most 200 chars of `s` (names and labels from the file stay readable in warnings). -pub(super) fn clip_str(s: &str) -> &str { +pub(crate) fn clip_str(s: &str) -> &str { s.char_indices().nth(200).and_then(|(i, _)| s.get(..i)).unwrap_or(s) } diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index d7117fd0..282476ef 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -186,6 +186,24 @@ fn paints_gradients_and_image_placeholders() { assert!(warnings.iter().any(|w| w.starts_with("1 image without image data shows")), "{warnings:?}"); } +#[test] +fn public_rest_image_filter_tint_keeps_its_sign() { + let rect = |name: &str, tint: f64| { + json!({ + "id": name, "name": name, "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}, + "fills": [{"type": "IMAGE", "imageRef": "tint", "filters": {"tint": tint}}] + }) + }; + let (doc, warnings) = one(json!([rect("Magenta", 0.75), rect("Green", -0.5)])); + let image = |name: &str| { + let PaintKind::Image(image) = &named(&doc, name).fills[0].kind else { panic!("{name} is not an image fill") }; + image + }; + assert!(close(f64::from(image("Magenta").filters.tint), 0.75)); + assert!(close(f64::from(image("Green").filters.tint), -0.5)); + assert!(!warnings.iter().any(|w| w.contains("approximate renderer")), "{warnings:?}"); +} + #[test] fn image_bytes_come_from_the_resolver() { let png = { From 2ac4ce3a730f02fef392c0f6fcb28ada4dfcb10e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:58:41 +0700 Subject: [PATCH 082/118] #635: rebase onto #628: the filter tests add their paint fields themselves The test schema extended the image scale-mode field that only #634 adds; it now adds the filter containers to the paint message itself, so it works with and without #634. --- crates/format/src/fig/image_filter_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/format/src/fig/image_filter_tests.rs b/crates/format/src/fig/image_filter_tests.rs index b8d64694..3216b2b3 100644 --- a/crates/format/src/fig/image_filter_tests.rs +++ b/crates/format/src/fig/image_filter_tests.rs @@ -12,7 +12,7 @@ fn image_filter_schema() -> Schema { message PaintFilterMessage float tint, float shadows, float highlights, float detail, float exposure, float vignette, float temperature, float vibrance, float contrast, float brightness, float mystery\n\ message Paint ", ) - .replace("ImageScaleMode imageScaleMode\n", "ImageScaleMode imageScaleMode, PaintFilterMessage paintFilter, FilterColorAdjust filterColorAdjust\n"); + .replace("message Paint PaintType type,", "message Paint PaintFilterMessage paintFilter, FilterColorAdjust filterColorAdjust, PaintType type,"); Schema::parse(&text).unwrap() } From baa58b21faa220a9920586bff2106e84286025f0 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:59:03 +0700 Subject: [PATCH 083/118] #620: file.open with base64 data returns the summary too --- crates/engine/src/cmd/file.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ab459e13..af2ac56d 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -698,9 +698,10 @@ fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>) let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|e| bad("file.open", e.to_string()))?; let (loaded, rest) = load_with(&bytes, &name, variables, &figcraft_format::rest::no_images)?; let warnings = rest || ext(&name) == "svg"; + let summary = import_summary(&loaded.doc); s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches)); s.active = s.docs.len() - 1; - let mut out = json!({"index": s.active}); + let mut out = json!({"index": s.active, "summary": summary}); out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues); Ok(out) } From 48d74df226894457390f4968ab76fec276526846 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:03:57 +0700 Subject: [PATCH 084/118] #635: one tint sign for REST file JSON and .fig Both carry the same image filter value, which the public docs describe as green when lower and magenta when higher; a positive native tint adds green. Both readers flip it now (the .fig path did, the REST path didn't). The .fig test message no longer names the product. --- crates/format/src/fig/image_filter_tests.rs | 2 +- crates/format/src/rest/props.rs | 5 +++-- crates/format/src/rest/tests.rs | 7 ++++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/format/src/fig/image_filter_tests.rs b/crates/format/src/fig/image_filter_tests.rs index 3216b2b3..83792123 100644 --- a/crates/format/src/fig/image_filter_tests.rs +++ b/crates/format/src/fig/image_filter_tests.rs @@ -66,7 +66,7 @@ fn image_filters_import_from_current_container_and_skip_ambiguous_data() { assert!((current.filters.contrast - 0.3).abs() < 1e-6, "observed raw contrast remains 0.3"); assert!((current.filters.saturation + 0.4).abs() < 1e-6, "vibrance maps to saturation"); assert!((current.filters.temperature - 0.5).abs() < 1e-6); - assert!((current.filters.tint + 1.0).abs() < 1e-6, "positive Figma tint converts once at the REST boundary"); + assert!((current.filters.tint + 1.0).abs() < 1e-6, "a positive (magenta) tint in the file is a negative native tint"); assert!((current.filters.highlights + 0.6).abs() < 1e-6); assert!((current.filters.shadows - 0.7).abs() < 1e-6); assert!((get("Negative tint").filters.tint - 1.0).abs() < 1e-6); diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index ac4d3519..0ee2d687 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -207,8 +207,9 @@ fn image(cx: &mut Ctx, p: &Value) -> ImagePaint { contrast: filter("contrast"), saturation: filter("saturation"), temperature: filter("temperature"), - // A positive tint in .fig sources moves toward magenta; native positive tint adds green. - tint: if fig { -filter("tint") } else { filter("tint") }, + // The files' tint runs from green (negative) to magenta (positive), in REST file JSON and + // .fig alike; a positive native tint adds green. + tint: -filter("tint"), highlights: filter("highlights"), shadows: filter("shadows"), }, diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index 282476ef..97e5d828 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -187,7 +187,7 @@ fn paints_gradients_and_image_placeholders() { } #[test] -fn public_rest_image_filter_tint_keeps_its_sign() { +fn image_filter_tint_turns_magenta_positive_into_native_green_positive() { let rect = |name: &str, tint: f64| { json!({ "id": name, "name": name, "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}, @@ -199,8 +199,9 @@ fn public_rest_image_filter_tint_keeps_its_sign() { let PaintKind::Image(image) = &named(&doc, name).fills[0].kind else { panic!("{name} is not an image fill") }; image }; - assert!(close(f64::from(image("Magenta").filters.tint), 0.75)); - assert!(close(f64::from(image("Green").filters.tint), -0.5)); + // The same sign as a `.fig` import (`fig::tests::image_filters`). + assert!(close(f64::from(image("Magenta").filters.tint), -0.75)); + assert!(close(f64::from(image("Green").filters.tint), 0.5)); assert!(!warnings.iter().any(|w| w.contains("approximate renderer")), "{warnings:?}"); } From 9e28e119548745adf9dbf09e96588ce79a9d83d9 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 17 Oct 2026 14:03:59 +0700 Subject: [PATCH 085/118] M3: number variables in the text content picker (#629) (#725) The Typography section's variable picker also lists number variables for a text layer's content (shown as the number), as the engine already accepted; other text fields keep their own types (#629). --- ROADMAP.md | 4 +++- crates/ui-egui/src/variable_ui.rs | 28 +++++++++++++++++++++++++++- docs/target-app-parity.md | 3 ++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index bbde8bc5..006b81b9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. - **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. @@ -314,6 +315,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | | 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/variable_ui.rs index 04507c0c..e6cc6860 100644 --- a/crates/ui-egui/src/variable_ui.rs +++ b/crates/ui-egui/src/variable_ui.rs @@ -361,6 +361,12 @@ pub fn bound_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], field: &str, kin }); } +/// Whether a variable of type `have` belongs in a picker for `want` on `field`: its own type, and +/// numbers for text content too (shown as text). +fn kind_fits(want: Option, field: Option<&str>, have: VariableType) -> bool { + want.is_none_or(|k| have == k || (field == Some("characters") && have == VariableType::Number)) +} + /// The picker popover (call once per frame). pub fn picker(app: &mut FigApp, ctx: &egui::Context) { let Some(p) = ctx.data(|d| d.get_temp::(picker_id())) else { return }; @@ -379,8 +385,12 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) { }; // Prototypes and styles use this file's variables. let local_only = matches!(p.target, Target::Proto(_) | Target::Style { .. }); + let field = match &p.target { + Target::Bind { field, .. } => Some(field.as_str()), + _ => None, + }; let keep = |lib: Option<&PublishedLibrary>, v: &Variable| { - p.kind.is_none_or(|k| v.kind == k) + kind_fits(p.kind, field, v.kind) && !(local_only && lib.is_some()) && (q.is_empty() || v.name.to_lowercase().contains(&q)) && scope.is_none_or(|s| v.in_scope(s)) @@ -476,3 +486,19 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) { fn groups_any_kind(doc: &Document, libs: &[Arc], kind: Option) -> bool { !groups(doc, libs, |_, v| kind.is_none_or(|k| v.kind == k)).is_empty() } + +#[cfg(test)] +mod kind_tests { + use super::*; + + #[test] + fn text_content_offers_numbers_too() { + use VariableType::*; + assert!(kind_fits(Some(String), Some("characters"), Number), "a number shows as text"); + assert!(kind_fits(Some(String), Some("characters"), String)); + assert!(!kind_fits(Some(String), Some("characters"), Color)); + assert!(!kind_fits(Some(String), Some("fontFamily"), Number), "only text content"); + assert!(!kind_fits(Some(Number), Some("width"), String)); + assert!(kind_fits(None, None, Boolean)); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7c89f239..c3842d4f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -628,6 +628,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | | 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | | 2026-10-13 | minor | Vector edit mode regions: the filled area under the pointer is hatched (clear of points and segments, holes left out, spacing by octave folding fitted to measurements at several zoom levels, fixed to the shape); click, Shift-click and drag select and move areas with their points (drawn unselected); a click on an empty spot clears them and stays in edit mode (see [ui-parity](ui-parity.md)) | From 1e85d549523a7908d173c61d18524c9a9a15451f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:04:00 +0700 Subject: [PATCH 086/118] #635: cap the .fig import warnings like the REST ones Image filter warnings name fields of the file, so a crafted schema could add any number of distinct warnings (each checked against all earlier ones). The mapper keeps at most as many as the REST importer does. --- crates/format/src/fig/image_filter_tests.rs | 21 +++++++++++++++++++++ crates/format/src/fig/map.rs | 3 ++- crates/format/src/rest/mod.rs | 2 +- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/format/src/fig/image_filter_tests.rs b/crates/format/src/fig/image_filter_tests.rs index 83792123..3fa50a20 100644 --- a/crates/format/src/fig/image_filter_tests.rs +++ b/crates/format/src/fig/image_filter_tests.rs @@ -81,3 +81,24 @@ fn image_filters_import_from_current_container_and_skip_ambiguous_data() { } assert_eq!(warnings.iter().filter(|w| w.contains("approximate renderer")).count(), 1, "deduplicated approximation warning: {warnings:?}"); } + +#[test] +fn warnings_naming_unknown_filter_fields_are_capped() { + // A schema can name any number of fields; each nonzero unknown one is a warning of its own. + let names: Vec<&'static str> = (0..300).map(|i| &*Box::leak(format!("unknown{i}").into_boxed_str())).collect(); + let fields: Vec = names.iter().map(|n| format!("float {n}")).collect(); + let text = SCHEMA + .replace("message Paint ", &format!("message ManyFilters {}\nmessage Paint ", fields.join(", "))) + .replace("message Paint PaintType type,", "message Paint ManyFilters paintFilter, PaintType type,"); + let filters = o(names.iter().map(|n| (*n, f(0.5))).collect()); + let paint = o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(HASH.to_vec()))])), ("paintFilter", filters)]); + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + node(2, Some((1, "!")), "RECTANGLE", "Photo", vec![("size", xy(2.0, 1.0)), ("fillPaints", arr(vec![paint]))]), + ]; + let bytes = zip_file(&canvas(&Schema::parse(&text).unwrap(), &o(vec![("nodeChanges", arr(nodes))]))); + let (_, warnings) = import(&bytes, "ManyFilters.fig").unwrap(); + let named = warnings.iter().filter(|w| w.contains("unsupported image filter")).count(); + assert!((100..=crate::rest::MAX_WARNINGS).contains(&named), "{named} of {} warnings", warnings.len()); +} diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 62d10d71..9dc238dc 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -431,7 +431,8 @@ impl<'a> Mapper<'a> { fn warn(&mut self, w: impl Into) { let w = w.into(); - if !self.warnings.contains(&w) { + // Capped like the REST importer's list: some warnings name fields of the file (image filters). + if self.warnings.len() < crate::rest::MAX_WARNINGS && !self.warnings.contains(&w) { self.warnings.push(w); } } diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 282c6a75..d42b04f3 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -66,7 +66,7 @@ const MAX_PATH_BYTES: usize = 4 << 20; /// Most path elements in one vector. const MAX_PATH_ELEMENTS: usize = 200_000; /// Most distinct warnings reported. -const MAX_WARNINGS: usize = 200; +pub(crate) const MAX_WARNINGS: usize = 200; /// Most shared style definitions read. const MAX_STYLES: usize = 10_000; /// Image key used for image fills without an `imageRef`. From d0539b83e9893329995c922926ff94fe5514e741 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:06:43 +0700 Subject: [PATCH 087/118] #631: follow the decoder rename in #639's connector check #639 lands first and reads networks with decode_styled; once #631 drops the count it calls network::decode. Needed whenever #631 is rebased onto a main that has #639. --- crates/format/src/fig/map.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 3ccf3a97..7a3dab5c 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1074,8 +1074,8 @@ impl<'a> Mapper<'a> { .get("vectorData") .and_then(|data| num(data, "vectorNetworkBlob")) .and_then(|index| self.blobs.get(index as usize)) - .and_then(|blob| network::decode_styled(blob)) - .is_some_and(|(network, _)| !network.segments.is_empty()); + .and_then(|blob| network::decode(blob)) + .is_some_and(|network| !network.segments.is_empty()); (geometry || network) && connector_record(v, "nodeGenerationData", 0).is_some() } From f757044d8cf91e63277481018d381716f7a76b80 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:08:05 +0700 Subject: [PATCH 088/118] fix/all: rustfmt the merged module list --- crates/format/src/fig/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index ca01611f..0d341307 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -28,12 +28,12 @@ mod slot_tests; mod source_identity_tests; #[cfg(test)] mod tests; +#[cfg(test)] +mod text_paragraph_tests; mod text_path; #[cfg(test)] mod text_path_tests; #[cfg(test)] -mod text_paragraph_tests; -#[cfg(test)] mod text_skip_ink_tests; #[cfg(test)] mod text_style_tests; From d40728d0b8ca7aadaddd1b25735c1e0536de8d12 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:21:29 +0700 Subject: [PATCH 089/118] #620: import notes come from the importers' own warn() texts, translated once when the report opens --- crates/ui-egui/src/i18n.rs | 11 ++- crates/ui-egui/src/i18n_tests.rs | 37 +++++++-- crates/ui-egui/src/import_report.rs | 94 +++-------------------- crates/ui-egui/src/import_report_tests.rs | 4 +- crates/ui-egui/src/menus.rs | 8 +- 5 files changed, 57 insertions(+), 97 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index d30130fb..22ea1a72 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -108,9 +108,14 @@ fn lookup(lang: usize, key: &str) -> Option<&'static Text> { /// The UI string for `key` (its English text) in the current language. One English text that needs /// two translations gets a context after `|` ("Fill|sizing"); the English catalog maps it back. pub fn tr(key: &str) -> &str { - match lookup(CURRENT.load(Ordering::Relaxed), key) { - Some(Text::Plain(s) | Text::Plural { other: s, .. }) => s, - None => key, + try_tr(key).unwrap_or(key) +} + +/// [`tr`] for text that comes from data and may have no catalog entry (an importer's notes, say): +/// `None` when the catalog doesn't have it. +pub fn try_tr(key: &str) -> Option<&str> { + match lookup(CURRENT.load(Ordering::Relaxed), key)? { + Text::Plain(s) | Text::Plural { other: s, .. } => Some(s), } } diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index 07f32037..16a06a90 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -160,15 +160,38 @@ fn used_keys() -> BTreeSet { if !name.ends_with(".rs") || name.ends_with("_tests.rs") || name == "i18n.rs" { continue; } - keys.extend(literals(&std::fs::read_to_string(&path).unwrap())); + keys.extend(literals(&std::fs::read_to_string(&path).unwrap(), LOOKUPS)); } keys } -/// The first-argument string literals of the lookup functions in `src`. -fn literals(src: &str) -> Vec { +/// The importers' fixed notes: the string literals passed to `warn` in the `.fig` and REST import +/// sources. The import report shows a note translated when the catalog has it, so an entry for one +/// counts as used, and an entry for a note the importers no longer give shows up as unused. +fn importer_notes() -> BTreeSet { + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../format/src"); + let mut keys = BTreeSet::new(); + for dir in ["fig", "rest"] { + for entry in std::fs::read_dir(src.join(dir)).unwrap().flatten() { + let path = entry.path(); + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + // export.rs warns about what a `.fig` export leaves out, not about an import. + if !name.ends_with(".rs") || name.contains("tests") || name == "export.rs" { + continue; + } + keys.extend(literals(&std::fs::read_to_string(&path).unwrap(), &["warn("])); + } + } + keys +} + +/// The lookup functions whose first argument is a catalog key. +const LOOKUPS: &[&str] = &["tr(", "trf(", "trn(", "tr_noop("]; + +/// The first-argument string literals of the functions `fns` (each with its `(`) in `src`. +fn literals(src: &str, fns: &[&str]) -> Vec { let mut out = Vec::new(); - for f in ["tr(", "trf(", "trn(", "tr_noop("] { + for f in fns { let mut from = 0; while let Some(i) = src.get(from..).and_then(|s| s.find(f)) { let at = from + i; @@ -229,7 +252,8 @@ fn every_key_in_the_code_is_in_the_english_catalog_and_no_other() { let used: BTreeSet = used_keys().into_iter().chain(dynamic_keys()).collect(); let en: BTreeSet = english().ui.iter().map(|(k, _)| k.to_string()).collect(); let missing: Vec<_> = used.difference(&en).collect(); - let unused: Vec<_> = en.difference(&used).collect(); + let notes = importer_notes(); + let unused: Vec<_> = en.difference(&used).filter(|k| !notes.contains(*k)).collect(); assert!( missing.is_empty(), "keys missing from locales/en.toml:\n{}", @@ -416,7 +440,8 @@ fn the_key_scanner_reads_rust_literals() { let src = r#"x(tr("A \"b\"")); trf( "C {n}", &[]); attr("no"); tr_noop("D\ E"); tr(label);"#; - assert_eq!(literals(src), vec!["A \"b\"", "C {n}", "DE"]); + assert_eq!(literals(src, LOOKUPS), vec!["A \"b\"", "C {n}", "DE"]); + assert_eq!(literals(r#"self.warn("a note"); s.warn(format!("{n}")); forewarn("no")"#, &["warn("]), vec!["a note"]); } #[test] diff --git a/crates/ui-egui/src/import_report.rs b/crates/ui-egui/src/import_report.rs index 0540a2fc..5b524727 100644 --- a/crates/ui-egui/src/import_report.rs +++ b/crates/ui-egui/src/import_report.rs @@ -5,85 +5,13 @@ use egui::RichText; use serde_json::{Value, json}; use crate::FigApp; -use crate::i18n::{tr, tr_noop, trn}; +use crate::i18n::{tr, trn, try_tr}; use crate::theme::{Tokens, regular}; /// A report stays small even when a damaged input produces many or very long notes. pub const MAX_NOTES: usize = 500; pub const MAX_NOTE_CHARS: usize = 2_000; -/// The fixed notes of the `.fig` and REST importers, translated in the report. `.fig` imports name -/// "the file" where REST imports name the JSON they read. Other notes (SVG's, say) are shown as they are. -const IMPORT_NOTES: &[&str] = &[ - tr_noop("conditional prototype actions nested too deeply were skipped"), - tr_noop("conditional prototype actions with conditions that can't be evaluated were skipped"), - tr_noop("connector labels were skipped"), - tr_noop("connectors whose ends can't be placed are empty"), - tr_noop("damaged vector geometry was skipped"), - tr_noop("damaged vector networks import as outlines"), - tr_noop("document children other than pages were skipped"), - tr_noop("elbowed and curved connectors without a saved path import as straight lines"), - tr_noop("entries without a node type were skipped"), - tr_noop("filled areas of a vector with their own fill use the layer's fill"), - tr_noop("gamepad triggers without exactly one known controller button were skipped"), - tr_noop("grid tracks of fixed or content size import as equal tracks"), - tr_noop("instance swap properties whose default component isn't in the file were skipped"), - tr_noop("instances inside their own main component were imported as frames"), - tr_noop("instances of components that aren't in the file show no layers"), - tr_noop("instances of components that aren't in the file were imported as frames"), - tr_noop("instances that would nest a component inside itself were imported as frames"), - tr_noop("instances without a main component were imported as empty frames"), - tr_noop("invalid or oversized vector networks import as outlines"), - tr_noop("invalid or oversized vector path data was skipped"), - tr_noop("key triggers without a supported key were skipped"), - tr_noop("key up triggers import as key presses"), - tr_noop("layers without a position were placed at their parent's origin"), - tr_noop("layers without a type were skipped"), - tr_noop("points with their own stroke join use the layer's"), - tr_noop("prototype actions of unknown types were skipped"), - tr_noop("prototype actions without a destination in the file were skipped"), - tr_noop("prototype expressions on variables that aren't in the variables JSON were skipped"), - tr_noop("prototype expressions with unsupported functions were skipped"), - tr_noop("prototype interactions without supported actions were skipped"), - tr_noop("prototype navigations of unknown types were skipped"), - tr_noop("prototype transitions of unknown types play instantly"), - tr_noop("prototype triggers of unknown types were skipped"), - tr_noop("set-variable actions on variables that aren't in the variables JSON were skipped"), - tr_noop("set-variable actions with values that can't be evaluated were skipped"), - tr_noop("set-variable actions with values that don't fit the variable were skipped"), - tr_noop("set-variable actions without a value were skipped"), - tr_noop("set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped"), - tr_noop("some vector points refer to point settings missing from the file; they use the layer's joins and plain corners"), - tr_noop("some vector segments refer to style settings missing from the file; they use the layer's stroke"), - tr_noop("stars and polygons without point counts or geometry use the default point count"), - tr_noop("style settings of some vector segments aren't supported; those segments use the layer's stroke"), - tr_noop("text style overrides refer to missing table entries"), - tr_noop("the file has no pages"), - tr_noop("the variables JSON has no `variableCollections` (expected the local variables response) and was ignored"), - tr_noop("unknown component property types were skipped"), - tr_noop("variable aliases to variables that aren't in the variables JSON (or of another type) were skipped"), - tr_noop("variable bindings to properties the layer doesn't have were skipped"), - tr_noop("variable bindings to variables that aren't in the variables JSON (library variables) were skipped"), - tr_noop( - "variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)", - ), - tr_noop("variable bindings whose variable type doesn't fit the property were skipped"), - tr_noop("variable collections without modes were skipped"), - tr_noop("variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped"), - tr_noop("variable modes of collections that aren't in the file were skipped"), - tr_noop("variable modes of collections that aren't in the variables JSON were skipped"), - tr_noop("variable values that don't match their type (or composed colours) were skipped"), - tr_noop("variables of unknown types were skipped"), - tr_noop("variables whose collection isn't in the variables JSON were skipped"), - tr_noop("variables without readable values were skipped"), - tr_noop("vectors without geometry are empty"), - tr_noop("vectors without geometry are empty (export the file JSON with geometry=paths)"), - tr_noop("very long text was cut to 1,048,576 characters"), - tr_noop("very long vector paths were skipped"), - tr_noop("video actions are not supported and were skipped"), - tr_noop("video triggers are not supported and were skipped"), -]; - #[derive(Clone, Debug)] pub struct Report { pub name: String, @@ -97,6 +25,8 @@ pub struct Report { /// The document the report belongs to: it goes when that document closes. pub uid: u64, pub notes: Vec, + /// `notes` as the window shows them, in the UI language (worked out once, not every frame). + pub shown: Vec, pub note_count: usize, } @@ -136,6 +66,7 @@ impl Report { placed: command == "file.import" && !appended, uid: 0, notes: Vec::new(), + shown: Vec::new(), note_count: 0, }; for note in warnings.iter().filter_map(Value::as_str) { @@ -151,6 +82,7 @@ impl Report { if note.chars().nth(MAX_NOTE_CHARS).is_some() { text.push('…'); } + self.shown.push(note_text(&text)); self.notes.push(text); } } @@ -161,15 +93,13 @@ impl Report { } } -/// `note` in the UI language when it is a known import note; a repeat count (` (×N)`) is kept. The -/// stored notes are lower-case fragments; a known note is shown as a sentence, so its first letter -/// is capitalised here (unknown notes are left as they are). +/// `note` in the UI language when the catalog has it (the importers' fixed notes: `.fig` imports +/// name "the file" where REST imports name the JSON they read); a repeat count (` (×N)`) is kept. +/// The stored notes are lower-case fragments; a known note is shown as a sentence, so its first +/// letter is capitalised here. Other notes (SVG's, say) are shown as they are. pub(crate) fn note_text(note: &str) -> String { let translated = |base: &str| { - if !IMPORT_NOTES.contains(&base) { - return base.to_owned(); - } - let text = tr(base); + let Some(text) = try_tr(base) else { return base.to_owned() }; let mut chars = text.chars(); chars.next().map_or_else(String::new, |first| first.to_uppercase().chain(chars).collect()) }; @@ -230,8 +160,8 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { ); ui.add_space(4.0); egui::ScrollArea::vertical().id_salt("import_report_notes").max_height(260.0).show(ui, |ui| { - for note in &report.notes { - ui.label(note_text(note)); + for note in &report.shown { + ui.label(note); ui.add_space(4.0); } }); diff --git a/crates/ui-egui/src/import_report_tests.rs b/crates/ui-egui/src/import_report_tests.rs index 37b51642..bc5fc8bb 100644 --- a/crates/ui-egui/src/import_report_tests.rs +++ b/crates/ui-egui/src/import_report_tests.rs @@ -63,8 +63,8 @@ fn note_text_keeps_the_repeat_suffix_and_unknown_notes() { fn vector_style_notes_are_known_import_notes() { for note in [ "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners", - "some vector segments refer to style settings missing from the file; they use the layer's stroke", - "style settings of some vector segments aren't supported; those segments use the layer's stroke", + "vector points with unknown stroke joins use the layer join", + "point stroke joins were preserved; dashed or variable-width strokes render with the layer join", ] { let shown = note_text(note); assert!(shown.chars().next().is_some_and(char::is_uppercase), "{shown}"); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index dd7ec509..31b53429 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -239,20 +239,20 @@ fn recent_menu(app: &mut FigApp, ui: &mut Ui, prefix: &str, key: &str, label: &s let name = path.file_name().map_or_else(|| p.clone(), |f| f.to_string_lossy().into_owned()); let exists = path.exists(); if item(ui, &name, "", exists, false, t) && exists { - picked = Some(("file.openRecent", json!({"path": p}))); + picked = Some(json!({"path": p})); } } if !recents.is_empty() { separator(ui); } if item(ui, tr("Clear recent"), "", !recents.is_empty(), false, t) && !recents.is_empty() { - picked = Some(("file.openRecent", json!({"clear": true}))); + picked = Some(json!({"clear": true})); } picked }); - let Some((command, params)) = picked else { return false }; + let Some(params) = picked else { return false }; // Through the user path, so an import report shows; `file.openRecent` also drops a file that is gone. - if app.run_user(command, params).is_some_and(|r| r.get("index").is_some()) { + if app.run_user("file.openRecent", params).is_some_and(|r| r.get("index").is_some()) { app.ui.home = false; } true From 01c3f0bca6b78f86c8b27474b8bf852e108a4c5b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:21:29 +0700 Subject: [PATCH 090/118] #620: the import report and its notes in every catalog; #617's notes added, stale and unshipped ones dropped --- crates/ui-egui/locales/cs.toml | 74 +++++++++++++++++++++++++++++ crates/ui-egui/locales/de.toml | 7 ++- crates/ui-egui/locales/en.toml | 5 +- crates/ui-egui/locales/es-419.toml | 3 ++ crates/ui-egui/locales/es.toml | 5 +- crates/ui-egui/locales/fr.toml | 74 +++++++++++++++++++++++++++++ crates/ui-egui/locales/id.toml | 74 +++++++++++++++++++++++++++++ crates/ui-egui/locales/ja.toml | 74 +++++++++++++++++++++++++++++ crates/ui-egui/locales/ko.toml | 74 +++++++++++++++++++++++++++++ crates/ui-egui/locales/pt-BR.toml | 74 +++++++++++++++++++++++++++++ crates/ui-egui/locales/ru.toml | 74 +++++++++++++++++++++++++++++ crates/ui-egui/locales/sv.toml | 74 +++++++++++++++++++++++++++++ crates/ui-egui/locales/tr.toml | 74 +++++++++++++++++++++++++++++ crates/ui-egui/locales/zh-Hans.toml | 74 +++++++++++++++++++++++++++++ 14 files changed, 750 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 268ff527..a13d12db 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -266,6 +266,7 @@ status = "draft — needs native review" "Commits" = "Commity" "Company" = "Společnost" "Compare changes" = "Porovnat změny" +"Compatibility notes" = "Poznámky ke kompatibilitě" "Completed" = "Dokončeno" "Component" = "Komponenta" "Component name" = "Název komponenty" @@ -472,6 +473,7 @@ status = "draft — needs native review" "False" = "Nepravda" "Feb" = "února" "Fetching from the server…" = "Stahování ze serveru…" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft zatím nepodporuje všechny funkce těchto souborů. Poznámky uvádějí, co bylo v importované kopii vynecháno nebo změněno; původní soubor zůstává beze změny." "File" = "Soubor" "File name" = "Název souboru" "File not found" = "Soubor nenalezen" @@ -595,11 +597,14 @@ status = "draft — needs native review" "If {condition}" = "Pokud {condition}" "Ignore auto layout (absolute position)" = "Ignorovat automatické rozvržení (absolutní poloha)" "Image" = "Obrázek" +"Images" = "Obrázky" "Import" = "Import" "Import path" = "Cesta importu" "Import plugin from manifest" = "Importovat plugin z manifestu" "Import plugin from manifest…" = "Importovat plugin z manifestu…" +"Import report" = "Zpráva o importu" "Import variables" = "Importovat proměnné" +"Imported document" = "Importovaný dokument" "Imported {name}" = "Importováno: {name}" "Imported {n} new variables, updated {updated}" = { one = "Importována {n} nová proměnná, aktualizováno: {updated}", few = "Importovány {n} nové proměnné, aktualizováno: {updated}", other = "Importováno {n} nových proměnných, aktualizováno: {updated}" } "Import…" = "Importovat…" @@ -801,6 +806,7 @@ status = "draft — needs native review" "No colour variables fit here." = "Žádné vhodné barevné proměnné." "No colour variables yet. Create them in Local variables." = "Zatím žádné barevné proměnné. Vytvořte je v Místních proměnných." "No comments yet" = "Zatím žádné komentáře" +"No compatibility warnings reported" = "Nebyla hlášena žádná upozornění na kompatibilitu" "No components yet. Select layers and press {keys}." = "Zatím žádné komponenty. Vyberte vrstvy a stiskněte {keys}." "No conflicts." = "Žádné konflikty." "No contributor data was built into this copy." = "Toto sestavení neobsahuje údaje o přispěvatelích." @@ -892,6 +898,7 @@ status = "draft — needs native review" "Open overlay" = "Otevřít překryv" "Open prototype preview" = "Otevřít náhled prototypu" "Open variables ({count})" = "Otevřít proměnné ({count})" +"Opened as a new document" = "Otevřeno jako nový dokument" "Opened “{name}” in a new tab" = "Soubor „{name}“ otevřen v nové kartě" "Open|comments" = "Otevřené" "Options" = "Možnosti" @@ -916,6 +923,8 @@ status = "draft — needs native review" "Padding" = "Vnitřní odsazení" "Page" = "Stránka" "Pages" = "Stránky" +"Pages added to the document" = "Stránky přidány do dokumentu" +"Placed on the current page" = "Umístěno na aktuální stránku" "Pages: {pages}" = "Stránky: {pages}" "Paint bucket" = "Plechovka barvy" "Pan the canvas" = "Posunout plátno" @@ -1485,26 +1494,90 @@ status = "draft — needs native review" "call any command its access allows" = "volat libovolný příkaz povolený jeho oprávněními" "call only: {commands}" = "volat pouze: {commands}" "can edit the document" = "může upravovat dokument" +"conditional prototype actions nested too deeply were skipped" = "podmíněné akce prototypu vnořené příliš hluboko byly přeskočeny" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "podmíněné akce prototypu s podmínkami, které nelze vyhodnotit, byly přeskočeny" +"connector labels were skipped" = "popisky spojnic byly přeskočeny" +"connectors whose ends can't be placed are empty" = "spojnice, jejichž konce nelze umístit, jsou prázdné" "crop marks" = "ořezové značky" +"damaged vector geometry was skipped" = "poškozená vektorová geometrie byla přeskočena" +"damaged vector networks import as outlines" = "poškozené vektorové sítě se importují jako obrysy" "development: {path}" = "vývoj: {path}" +"document children other than pages were skipped" = "potomci dokumentu, kteří nejsou stránkami, byli přeskočeni" "e.g. count + 1" = "např. count + 1" "e.g. count >= 3 and not done" = "např. count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "lomené a zakřivené spojnice bez uložené cesty se importují jako přímky" +"entries without a node type were skipped" = "položky bez typu uzlu byly přeskočeny" "figcraft:// links from a browser, chat or email open this app and join the session." = "Odkazy figcraft:// z prohlížeče, chatu nebo e-mailu otevřou tuto aplikaci a připojí k relaci." +"filled areas of a vector with their own fill use the layer's fill" = "vyplněné oblasti vektoru s vlastní výplní používají výplň vrstvy" "from {server}" = "ze serveru {server}" +"gamepad triggers without exactly one known controller button were skipped" = "spouštěče herního ovladače bez právě jednoho známého tlačítka ovladače byly přeskočeny" +"grid tracks of fixed or content size import as equal tracks" = "stopy mřížky s pevnou velikostí nebo velikostí podle obsahu se importují jako stejné stopy" "hidden" = "skryté" "iOS" = "iOS" +"instance swap properties whose default component isn't in the file were skipped" = "vlastnosti výměny instance, jejichž výchozí komponenta není v souboru, byly přeskočeny" +"instances inside their own main component were imported as frames" = "instance uvnitř vlastní hlavní komponenty byly importovány jako rámce" +"instances of components that aren't in the file show no layers" = "instance komponent, které nejsou v souboru, nezobrazují žádné vrstvy" +"instances of components that aren't in the file were imported as frames" = "instance komponent, které nejsou v souboru, byly importovány jako rámce" +"instances that would nest a component inside itself were imported as frames" = "instance, které by vnořily komponentu samu do sebe, byly importovány jako rámce" +"instances without a main component were imported as empty frames" = "instance bez hlavní komponenty byly importovány jako prázdné rámce" +"invalid or oversized vector networks import as outlines" = "neplatné nebo příliš velké vektorové sítě se importují jako obrysy" +"invalid or oversized vector path data was skipped" = "neplatná nebo příliš velká data vektorových cest byla přeskočena" "just now" = "právě teď" +"key triggers without a supported key were skipped" = "spouštěče kláves bez podporované klávesy byly přeskočeny" +"key up triggers import as key presses" = "spouštěče uvolnění klávesy se importují jako stisky klávesy" +"layers without a position were placed at their parent's origin" = "vrstvy bez pozice byly umístěny do počátku nadřazeného prvku" +"layers without a type were skipped" = "vrstvy bez typu byly přeskočeny" "locked" = "zamknuté" "no bleed" = "bez spadávky" "nobody else here yet" = "zatím tu není nikdo další" "now" = "nyní" "or" = "nebo" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "spojení tahu v bodech byla zachována; přerušované tahy a tahy s proměnnou šířkou se kreslí se spojením vrstvy" +"prototype actions of unknown types were skipped" = "akce prototypu neznámých typů byly přeskočeny" +"prototype actions without a destination in the file were skipped" = "akce prototypu bez cíle v souboru byly přeskočeny" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "výrazy prototypu s proměnnými, které nejsou v JSON proměnných, byly přeskočeny" +"prototype expressions with unsupported functions were skipped" = "výrazy prototypu s nepodporovanými funkcemi byly přeskočeny" +"prototype interactions without supported actions were skipped" = "interakce prototypu bez podporovaných akcí byly přeskočeny" +"prototype navigations of unknown types were skipped" = "navigace prototypu neznámých typů byly přeskočeny" +"prototype transitions of unknown types play instantly" = "přechody prototypu neznámých typů proběhnou okamžitě" +"prototype triggers of unknown types were skipped" = "spouštěče prototypu neznámých typů byly přeskočeny" "put a widget on the canvas" = "umístit widget na plátno" "read and edit the document" = "číst a upravovat dokument" "read the document" = "číst dokument" "reads the document" = "čte dokument" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "akce nastavení proměnné u proměnných, které nejsou v JSON proměnných, byly přeskočeny" +"set-variable actions with values that can't be evaluated were skipped" = "akce nastavení proměnné s hodnotami, které nelze vyhodnotit, byly přeskočeny" +"set-variable actions with values that don't fit the variable were skipped" = "akce nastavení proměnné s hodnotami, které neodpovídají proměnné, byly přeskočeny" +"set-variable actions without a value were skipped" = "akce nastavení proměnné bez hodnoty byly přeskočeny" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "akce nastavení režimu proměnných u kolekcí nebo režimů, které nejsou v JSON proměnných, byly přeskočeny" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "některé vektorové body odkazují na nastavení bodů, která v souboru chybí; používají spojení vrstvy a nezaoblené rohy" +"stars and polygons without point counts or geometry use the default point count" = "hvězdy a mnohoúhelníky bez počtu bodů nebo geometrie používají výchozí počet bodů" +"text style overrides refer to missing table entries" = "přepsání textových stylů odkazují na chybějící položky tabulky" +"the file has no pages" = "soubor nemá žádné stránky" "the plugin" = "plugin" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "JSON proměnných nemá `variableCollections` (očekávala se odpověď s místními proměnnými) a byl ignorován" "this session only" = "pouze v této relaci" +"unknown component property types were skipped" = "neznámé typy vlastností komponent byly přeskočeny" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "aliasy na proměnné, které nejsou v JSON proměnných (nebo jsou jiného typu), byly přeskočeny" +"variable bindings to properties the layer doesn't have were skipped" = "vazby proměnných na vlastnosti, které vrstva nemá, byly přeskočeny" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "vazby na proměnné, které nejsou v JSON proměnných (proměnné knihovny), byly přeskočeny" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "vazby proměnných byly přeskočeny: JSON souboru nemá definice proměnných (předejte JSON místních proměnných jako `variables` nebo vložte jeho `meta` vedle JSON souboru)" +"variable bindings whose variable type doesn't fit the property were skipped" = "vazby proměnných, jejichž typ neodpovídá vlastnosti, byly přeskočeny" +"variable collections without modes were skipped" = "kolekce proměnných bez režimů byly přeskočeny" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "vyhledání režimu proměnné ve výrazech prototypu, které nejsou proměnnou a jedním z jejích režimů, byla přeskočena" +"variable modes of collections that aren't in the file were skipped" = "režimy proměnných kolekcí, které nejsou v souboru, byly přeskočeny" +"variable modes of collections that aren't in the variables JSON were skipped" = "režimy proměnných kolekcí, které nejsou v JSON proměnných, byly přeskočeny" +"variable values that don't match their type (or composed colours) were skipped" = "hodnoty proměnných, které neodpovídají svému typu (nebo složené barvy), byly přeskočeny" +"variables of unknown types were skipped" = "proměnné neznámých typů byly přeskočeny" +"variables whose collection isn't in the variables JSON were skipped" = "proměnné, jejichž kolekce není v JSON proměnných, byly přeskočeny" +"variables without readable values were skipped" = "proměnné bez čitelných hodnot byly přeskočeny" +"vector points with unknown stroke joins use the layer join" = "vektorové body s neznámým spojením tahu používají spojení vrstvy" +"vectors without geometry are empty" = "vektory bez geometrie jsou prázdné" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "vektory bez geometrie jsou prázdné (exportujte JSON souboru s geometry=paths)" +"very long text was cut to 1,048,576 characters" = "velmi dlouhý text byl zkrácen na 1 048 576 znaků" +"very long vector paths were skipped" = "velmi dlouhé vektorové cesty byly přeskočeny" +"video actions are not supported and were skipped" = "akce videa nejsou podporovány a byly přeskočeny" +"video triggers are not supported and were skipped" = "spouštěče videa nejsou podporovány a byly přeskočeny" "yesterday" = "včera" "{instances} in {files}" = "{instances} · {files}" "{language}: draft translation, waiting for a native speaker's review." = "{language}: návrh překladu, čeká na kontrolu rodilým mluvčím." @@ -1549,6 +1622,7 @@ status = "draft — needs native review" "{n} matches" = { one = "{n} shoda", few = "{n} shody", other = "{n} shod" } "{n} minutes ago" = { one = "Před {n} minutou", few = "Před {n} minutami", other = "Před {n} minutami" } "{n} months ago" = { one = "Před {n} měsícem", few = "Před {n} měsíci", other = "Před {n} měsíci" } +"{n} more notes omitted from this report" = { one = "{n} další poznámka v této zprávě vynechána", few = "{n} další poznámky v této zprávě vynechány", other = "{n} dalších poznámek v této zprávě vynecháno" } "{n} of {total}" = "{n} z {total}" "{n} people joined" = { one = "Připojila se {n} osoba", few = "Připojily se {n} osoby", other = "Připojilo se {n} osob" } "{n} preferred components" = { one = "{n} preferovaná komponenta", few = "{n} preferované komponenty", other = "{n} preferovaných komponent" } diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 520d4ba6..4a28a7fc 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1481,7 +1481,7 @@ status = "draft — needs native review" "nobody else here yet" = "noch niemand sonst hier" "now" = "jetzt" "or" = "oder" -"points with their own stroke join use the layer's" = "Punkte mit eigener Konturecke verwenden die der Ebene" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "die Konturecken der Punkte wurden übernommen; gestrichelte Konturen und Konturen mit variabler Breite werden mit der Ecke der Ebene gezeichnet" "prototype actions of unknown types were skipped" = "Prototyp-Aktionen unbekannter Art wurden übersprungen" "prototype actions without a destination in the file were skipped" = "Prototyp-Aktionen ohne Ziel in der Datei wurden übersprungen" "prototype expressions on variables that aren't in the variables JSON were skipped" = "Prototyp-Ausdrücke mit Variablen, die nicht im Variablen-JSON sind, wurden übersprungen" @@ -1499,10 +1499,8 @@ status = "draft — needs native review" "set-variable actions with values that don't fit the variable were skipped" = "Aktionen „Variable festlegen“ mit Werten, die nicht zur Variable passen, wurden übersprungen" "set-variable actions without a value were skipped" = "Aktionen „Variable festlegen“ ohne Wert wurden übersprungen" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "Aktionen „Variablenmodus festlegen“ für Sammlungen oder Modi, die nicht im Variablen-JSON sind, wurden übersprungen" -"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "einige Vektorpunkte verweisen auf Punkteinstellungen, die in der Datei fehlen; sie verwenden die Konturecken der Ebene und einfache Ecken" -"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "einige Vektorsegmente verweisen auf Stileinstellungen, die in der Datei fehlen; sie verwenden die Kontur der Ebene" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "einige Vektorpunkte verweisen auf Punkteinstellungen, die in der Datei fehlen; sie übernehmen die Ecken der Ebenenkontur und bleiben ungerundet" "stars and polygons without point counts or geometry use the default point count" = "Sterne und Polygone ohne Punktanzahl oder Geometrie verwenden die Standard-Punktanzahl" -"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "Stileinstellungen einiger Vektorsegmente werden nicht unterstützt; diese Segmente verwenden die Kontur der Ebene" "text style overrides refer to missing table entries" = "Textstil-Überschreibungen verweisen auf fehlende Tabelleneinträge" "the file has no pages" = "die Datei hat keine Seiten" "the plugin" = "das Plugin" @@ -1522,6 +1520,7 @@ status = "draft — needs native review" "variables of unknown types were skipped" = "Variablen unbekannter Typen wurden übersprungen" "variables whose collection isn't in the variables JSON were skipped" = "Variablen, deren Sammlung nicht im Variablen-JSON ist, wurden übersprungen" "variables without readable values were skipped" = "Variablen ohne lesbare Werte wurden übersprungen" +"vector points with unknown stroke joins use the layer join" = "Vektorpunkte mit unbekannten Konturecken verwenden die Ecke der Ebene" "vectors without geometry are empty" = "Vektoren ohne Geometrie sind leer" "vectors without geometry are empty (export the file JSON with geometry=paths)" = "Vektoren ohne Geometrie sind leer (exportieren Sie das Datei-JSON mit geometry=paths)" "very long text was cut to 1,048,576 characters" = "sehr langer Text wurde auf 1.048.576 Zeichen gekürzt" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 50045c1e..de22405d 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1555,7 +1555,7 @@ name = "English" "nobody else here yet" = "nobody else here yet" "now" = "now" "or" = "or" -"points with their own stroke join use the layer's" = "points with their own stroke join use the layer's" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" "prototype actions of unknown types were skipped" = "prototype actions of unknown types were skipped" "prototype actions without a destination in the file were skipped" = "prototype actions without a destination in the file were skipped" "prototype expressions on variables that aren't in the variables JSON were skipped" = "prototype expressions on variables that aren't in the variables JSON were skipped" @@ -1574,9 +1574,7 @@ name = "English" "set-variable actions without a value were skipped" = "set-variable actions without a value were skipped" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" -"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "some vector segments refer to style settings missing from the file; they use the layer's stroke" "stars and polygons without point counts or geometry use the default point count" = "stars and polygons without point counts or geometry use the default point count" -"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "style settings of some vector segments aren't supported; those segments use the layer's stroke" "text style overrides refer to missing table entries" = "text style overrides refer to missing table entries" "the file has no pages" = "the file has no pages" "the plugin" = "the plugin" @@ -1596,6 +1594,7 @@ name = "English" "variables of unknown types were skipped" = "variables of unknown types were skipped" "variables whose collection isn't in the variables JSON were skipped" = "variables whose collection isn't in the variables JSON were skipped" "variables without readable values were skipped" = "variables without readable values were skipped" +"vector points with unknown stroke joins use the layer join" = "vector points with unknown stroke joins use the layer join" "vectors without geometry are empty" = "vectors without geometry are empty" "vectors without geometry are empty (export the file JSON with geometry=paths)" = "vectors without geometry are empty (export the file JSON with geometry=paths)" "very long text was cut to 1,048,576 characters" = "very long text was cut to 1,048,576 characters" diff --git a/crates/ui-egui/locales/es-419.toml b/crates/ui-egui/locales/es-419.toml index 6b42ac20..74ce97a8 100644 --- a/crates/ui-egui/locales/es-419.toml +++ b/crates/ui-egui/locales/es-419.toml @@ -66,6 +66,7 @@ status = "draft — needs native review" "No components yet. Select layers and press {keys}." = "Aún no hay componentes. Selecciona capas y presiona {keys}." "No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Aún no hay bibliotecas. Agrega un archivo .figcraft publicado como biblioteca o pega el enlace de una biblioteca de equipo." "PDF print settings" = "Configuración de impresión PDF" +"Pages added to the document" = "Páginas agregadas al documento" "Press a key…" = "Presiona una tecla…" "Prototype settings" = "Configuración del prototipo" "Remove export setting" = "Quitar configuración de exportación" @@ -80,6 +81,8 @@ status = "draft — needs native review" "Type settings" = "Configuración de texto" "With the layers' export settings" = "Con la configuración de exportación de las capas" "You'll get the shared file as a new tab and edit it together." = "Recibirás el archivo compartido en una pestaña nueva y lo editarán en conjunto." +"gamepad triggers without exactly one known controller button were skipped" = "se omitieron los desencadenantes de control sin exactamente un botón de control conocido" +"key up triggers import as key presses" = "los desencadenantes al soltar una tecla se importan como presiones de tecla" "{name} is forgotten on this computer; the file stays on {server}" = "{name} se olvidó en esta computadora; el archivo se queda en {server}" "{n} added" = { one = "{n} agregada", other = "{n} agregadas" } diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 8abaa332..3f08968b 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1556,7 +1556,7 @@ status = "draft — needs native review" "nobody else here yet" = "aún no hay nadie más" "now" = "ahora" "or" = "o" -"points with their own stroke join use the layer's" = "los puntos con su propia unión de trazo usan la de la capa" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "se conservaron las uniones de trazo de los puntos; los trazos discontinuos o de ancho variable se dibujan con la unión de la capa" "prototype actions of unknown types were skipped" = "se omitieron las acciones de prototipo de tipos desconocidos" "prototype actions without a destination in the file were skipped" = "se omitieron las acciones de prototipo sin destino en el archivo" "prototype expressions on variables that aren't in the variables JSON were skipped" = "se omitieron las expresiones de prototipo con variables que no están en el JSON de variables" @@ -1575,9 +1575,7 @@ status = "draft — needs native review" "set-variable actions without a value were skipped" = "se omitieron las acciones «Establecer variable» sin valor" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "se omitieron las acciones «Establecer modo de variable» sobre colecciones o modos que no están en el JSON de variables" "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "algunos puntos vectoriales hacen referencia a ajustes de punto que faltan en el archivo; usan las uniones de la capa y esquinas simples" -"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "algunos segmentos vectoriales hacen referencia a ajustes de estilo que faltan en el archivo; usan el trazo de la capa" "stars and polygons without point counts or geometry use the default point count" = "las estrellas y los polígonos sin número de puntos ni geometría usan el número de puntos predeterminado" -"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "los ajustes de estilo de algunos segmentos vectoriales no son compatibles; esos segmentos usan el trazo de la capa" "text style overrides refer to missing table entries" = "las anulaciones de estilo de texto hacen referencia a entradas de tabla que faltan" "the file has no pages" = "el archivo no tiene páginas" "the plugin" = "el plugin" @@ -1597,6 +1595,7 @@ status = "draft — needs native review" "variables of unknown types were skipped" = "se omitieron las variables de tipos desconocidos" "variables whose collection isn't in the variables JSON were skipped" = "se omitieron las variables cuya colección no está en el JSON de variables" "variables without readable values were skipped" = "se omitieron las variables sin valores legibles" +"vector points with unknown stroke joins use the layer join" = "los puntos vectoriales con uniones de trazo desconocidas usan la unión de la capa" "vectors without geometry are empty" = "los vectores sin geometría están vacíos" "vectors without geometry are empty (export the file JSON with geometry=paths)" = "los vectores sin geometría están vacíos (exporta el JSON del archivo con geometry=paths)" "very long text was cut to 1,048,576 characters" = "el texto muy largo se recortó a 1.048.576 caracteres" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 7fafd3c9..e1961711 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -260,6 +260,7 @@ status = "draft — needs native review" "Commits" = "Commits" "Company" = "Entreprise" "Compare changes" = "Comparer les modifications" +"Compatibility notes" = "Notes de compatibilité" "Completed" = "Terminé" "Component" = "Composant" "Component name" = "Nom du composant" @@ -460,6 +461,7 @@ status = "draft — needs native review" "False" = "Faux" "Fetching from the server…" = "Récupération depuis le serveur…" "FigCraft couldn't open its window: {error}" = "FigCraft n’a pas pu ouvrir sa fenêtre : {error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft ne prend pas encore en charge toutes les fonctionnalités de ces fichiers. Les notes indiquent ce qui a été omis ou modifié dans la copie importée ; le fichier d’origine reste inchangé." "File" = "Fichier" "File name" = "Nom du fichier" "File published" = "Fichier publié" @@ -576,11 +578,14 @@ status = "draft — needs native review" "If {condition}" = "Si {condition}" "Ignore auto layout (absolute position)" = "Ignorer la mise en page auto (position absolue)" "Image" = "Image" +"Images" = "Images" "Import" = "Importer" "Import path" = "Chemin d’importation" "Import plugin from manifest" = "Importer un plugin depuis un manifeste" "Import plugin from manifest…" = "Importer un plugin depuis un manifeste…" +"Import report" = "Rapport d’importation" "Import variables" = "Importer des variables" +"Imported document" = "Document importé" "Imported {name}" = "{name} importé" "Imported {n} new variables, updated {updated}" = { one = "{n} nouvelle variable importée, {updated} mises à jour", other = "{n} nouvelles variables importées, {updated} mises à jour" } "Import…" = "Importer…" @@ -770,6 +775,7 @@ status = "draft — needs native review" "No colour variables fit here." = "Aucune variable de couleur ne convient ici." "No colour variables yet. Create them in Local variables." = "Aucune variable de couleur pour l’instant. Créez-en dans Variables locales." "No comments yet" = "Aucun commentaire pour l’instant" +"No compatibility warnings reported" = "Aucun avertissement de compatibilité signalé" "No components yet. Select layers and press {keys}." = "Aucun composant pour l’instant. Sélectionnez des calques et appuyez sur {keys}." "No conflicts." = "Aucun conflit." "No contributor data was built into this copy." = "Cette copie ne contient aucune donnée de contributeurs." @@ -856,6 +862,7 @@ status = "draft — needs native review" "Open overlay" = "Ouvrir une superposition" "Open prototype preview" = "Ouvrir l’aperçu du prototype" "Open variables ({count})" = "Ouvrir les variables ({count})" +"Opened as a new document" = "Ouvert comme nouveau document" "Opened “{name}” in a new tab" = "« {name} » ouvert dans un nouvel onglet" "Open|comments" = "Ouverts" "Options" = "Options" @@ -879,6 +886,8 @@ status = "draft — needs native review" "Padding" = "Marge intérieure" "Page" = "Page" "Pages" = "Pages" +"Pages added to the document" = "Pages ajoutées au document" +"Placed on the current page" = "Placé sur la page actuelle" "Pages: {pages}" = "Pages : {pages}" "Paint bucket" = "Pot de peinture" "Pan the canvas" = "Faire défiler le canevas" @@ -1426,25 +1435,89 @@ status = "draft — needs native review" "call any command its access allows" = "appeler toute commande que son accès autorise" "call only: {commands}" = "appeler seulement : {commands}" "can edit the document" = "peut modifier le document" +"conditional prototype actions nested too deeply were skipped" = "les actions de prototype conditionnelles trop profondément imbriquées ont été ignorées" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "les actions de prototype conditionnelles dont les conditions ne peuvent pas être évaluées ont été ignorées" +"connector labels were skipped" = "les libellés des connecteurs ont été ignorés" +"connectors whose ends can't be placed are empty" = "les connecteurs dont les extrémités ne peuvent pas être placées sont vides" "crop marks" = "traits de coupe" +"damaged vector geometry was skipped" = "la géométrie vectorielle endommagée a été ignorée" +"damaged vector networks import as outlines" = "les réseaux vectoriels endommagés sont importés comme tracés" "development: {path}" = "développement : {path}" +"document children other than pages were skipped" = "les enfants du document autres que des pages ont été ignorés" "e.g. count + 1" = "p. ex. count + 1" "e.g. count >= 3 and not done" = "p. ex. count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "les connecteurs coudés et courbes sans tracé enregistré sont importés comme des lignes droites" +"entries without a node type were skipped" = "les entrées sans type de nœud ont été ignorées" "figcraft:// links from a browser, chat or email open this app and join the session." = "Les liens figcraft:// d’un navigateur, d’une messagerie ou d’un e-mail ouvrent cette application et rejoignent la session." +"filled areas of a vector with their own fill use the layer's fill" = "les zones remplies d’un vecteur avec leur propre remplissage utilisent le remplissage du calque" "from {server}" = "depuis {server}" +"gamepad triggers without exactly one known controller button were skipped" = "les déclencheurs de manette sans exactement un bouton de manette connu ont été ignorés" +"grid tracks of fixed or content size import as equal tracks" = "les pistes de grille de taille fixe ou ajustée au contenu sont importées comme des pistes égales" "hidden" = "masqué" "iOS" = "iOS" +"instance swap properties whose default component isn't in the file were skipped" = "les propriétés de permutation d’instance dont le composant par défaut n’est pas dans le fichier ont été ignorées" +"instances inside their own main component were imported as frames" = "les instances placées dans leur propre composant principal ont été importées comme cadres" +"instances of components that aren't in the file show no layers" = "les instances de composants absents du fichier n’affichent aucun calque" +"instances of components that aren't in the file were imported as frames" = "les instances de composants absents du fichier ont été importées comme cadres" +"instances that would nest a component inside itself were imported as frames" = "les instances qui imbriqueraient un composant dans lui-même ont été importées comme cadres" +"instances without a main component were imported as empty frames" = "les instances sans composant principal ont été importées comme cadres vides" +"invalid or oversized vector networks import as outlines" = "les réseaux vectoriels non valides ou trop grands sont importés comme tracés" +"invalid or oversized vector path data was skipped" = "les données de tracé vectoriel non valides ou trop grandes ont été ignorées" +"key triggers without a supported key were skipped" = "les déclencheurs de touche sans touche prise en charge ont été ignorés" +"key up triggers import as key presses" = "les déclencheurs au relâchement d’une touche sont importés comme des appuis sur la touche" +"layers without a position were placed at their parent's origin" = "les calques sans position ont été placés à l’origine de leur parent" +"layers without a type were skipped" = "les calques sans type ont été ignorés" "locked" = "verrouillé" "no bleed" = "sans fond perdu" "nobody else here yet" = "personne d’autre pour l’instant" "now" = "maintenant" "or" = "ou" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "les jointures des points ont été conservées ; les contours en pointillés ou à largeur variable sont dessinés avec la jointure du calque" +"prototype actions of unknown types were skipped" = "les actions de prototype de types inconnus ont été ignorées" +"prototype actions without a destination in the file were skipped" = "les actions de prototype sans destination dans le fichier ont été ignorées" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "les expressions de prototype portant sur des variables absentes du JSON des variables ont été ignorées" +"prototype expressions with unsupported functions were skipped" = "les expressions de prototype utilisant des fonctions non prises en charge ont été ignorées" +"prototype interactions without supported actions were skipped" = "les interactions de prototype sans action prise en charge ont été ignorées" +"prototype navigations of unknown types were skipped" = "les navigations de prototype de types inconnus ont été ignorées" +"prototype transitions of unknown types play instantly" = "les transitions de prototype de types inconnus sont instantanées" +"prototype triggers of unknown types were skipped" = "les déclencheurs de prototype de types inconnus ont été ignorés" "put a widget on the canvas" = "placer un widget sur le canevas" "read and edit the document" = "lire et modifier le document" "read the document" = "lire le document" "reads the document" = "lit le document" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "les actions de définition de variable portant sur des variables absentes du JSON des variables ont été ignorées" +"set-variable actions with values that can't be evaluated were skipped" = "les actions de définition de variable dont les valeurs ne peuvent pas être évaluées ont été ignorées" +"set-variable actions with values that don't fit the variable were skipped" = "les actions de définition de variable dont les valeurs ne correspondent pas à la variable ont été ignorées" +"set-variable actions without a value were skipped" = "les actions de définition de variable sans valeur ont été ignorées" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "les actions de définition du mode de variable portant sur des collections ou des modes absents du JSON des variables ont été ignorées" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "certains points vectoriels renvoient à des réglages de point absents du fichier ; ils utilisent les jointures du calque et des angles simples" +"stars and polygons without point counts or geometry use the default point count" = "les étoiles et polygones sans nombre de pointes ni géométrie utilisent le nombre de pointes par défaut" +"text style overrides refer to missing table entries" = "les remplacements de style de texte renvoient à des entrées de table manquantes" +"the file has no pages" = "le fichier n’a aucune page" "the plugin" = "le plugin" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "le JSON des variables n’a pas de `variableCollections` (la réponse des variables locales était attendue) et a été ignoré" "this session only" = "cette session uniquement" +"unknown component property types were skipped" = "les types de propriété de composant inconnus ont été ignorés" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "les alias vers des variables absentes du JSON des variables (ou d’un autre type) ont été ignorés" +"variable bindings to properties the layer doesn't have were skipped" = "les liaisons de variables à des propriétés que le calque n’a pas ont été ignorées" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "les liaisons à des variables absentes du JSON des variables (variables de bibliothèque) ont été ignorées" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "les liaisons de variables ont été ignorées : le JSON du fichier n’a aucune définition de variable (transmettez le JSON des variables locales comme `variables`, ou intégrez son `meta` à côté du JSON du fichier)" +"variable bindings whose variable type doesn't fit the property were skipped" = "les liaisons de variables dont le type de variable ne convient pas à la propriété ont été ignorées" +"variable collections without modes were skipped" = "les collections de variables sans mode ont été ignorées" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "les recherches de mode de variable dans les expressions de prototype qui ne sont pas une variable et l’un de ses modes ont été ignorées" +"variable modes of collections that aren't in the file were skipped" = "les modes de variables de collections absentes du fichier ont été ignorés" +"variable modes of collections that aren't in the variables JSON were skipped" = "les modes de variables de collections absentes du JSON des variables ont été ignorés" +"variable values that don't match their type (or composed colours) were skipped" = "les valeurs de variables qui ne correspondent pas à leur type (ou les couleurs composées) ont été ignorées" +"variables of unknown types were skipped" = "les variables de types inconnus ont été ignorées" +"variables whose collection isn't in the variables JSON were skipped" = "les variables dont la collection est absente du JSON des variables ont été ignorées" +"variables without readable values were skipped" = "les variables sans valeurs lisibles ont été ignorées" +"vector points with unknown stroke joins use the layer join" = "les points vectoriels aux jointures inconnues utilisent la jointure du calque" +"vectors without geometry are empty" = "les vecteurs sans géométrie sont vides" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "les vecteurs sans géométrie sont vides (exportez le JSON du fichier avec geometry=paths)" +"very long text was cut to 1,048,576 characters" = "le texte très long a été coupé à 1 048 576 caractères" +"very long vector paths were skipped" = "les tracés vectoriels très longs ont été ignorés" +"video actions are not supported and were skipped" = "les actions vidéo ne sont pas prises en charge et ont été ignorées" +"video triggers are not supported and were skipped" = "les déclencheurs vidéo ne sont pas pris en charge et ont été ignorés" "{instances} in {files}" = "{instances} dans {files}" "{language}: draft translation, waiting for a native speaker's review." = "{language} : traduction provisoire, en attente de relecture par un locuteur natif." "{length} bleed" = "fond perdu de {length}" @@ -1487,6 +1560,7 @@ status = "draft — needs native review" "{n} matches" = { one = "{n} résultat", other = "{n} résultats" } "{n} minutes ago" = { one = "il y a {n} minute", other = "il y a {n} minutes" } "{n} months ago" = { one = "il y a {n} mois", other = "il y a {n} mois" } +"{n} more notes omitted from this report" = { one = "{n} autre note omise de ce rapport", other = "{n} autres notes omises de ce rapport" } "{n} of {total}" = "{n} sur {total}" "{n} people joined" = { one = "{n} personne a rejoint la session", other = "{n} personnes ont rejoint la session" } "{n} preferred components" = { one = "{n} composant préféré", other = "{n} composants préférés" } diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 5de7634d..89475579 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -270,6 +270,7 @@ status = "draft — needs native review" "Commits" = "Commit" "Company" = "Perusahaan" "Compare changes" = "Bandingkan perubahan" +"Compatibility notes" = "Catatan kompatibilitas" "Completed" = "Selesai" "Component" = "Component" "Component name" = "Nama component" @@ -476,6 +477,7 @@ status = "draft — needs native review" "False" = "Salah" "Feb" = "Feb" "Fetching from the server…" = "Mengambil dari server…" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft belum mendukung semua fitur file ini. Catatan berisi apa yang dihilangkan atau diubah pada salinan yang diimpor; file aslinya tidak berubah." "File" = "File" "File name" = "Nama file" "File not found" = "File tidak ditemukan" @@ -599,11 +601,14 @@ status = "draft — needs native review" "If {condition}" = "Jika {condition}" "Ignore auto layout (absolute position)" = "Abaikan auto layout (posisi absolut)" "Image" = "Gambar" +"Images" = "Gambar" "Import" = "Impor" "Import path" = "Jalur impor" "Import plugin from manifest" = "Impor plugin dari manifest" "Import plugin from manifest…" = "Impor plugin dari manifest…" +"Import report" = "Laporan impor" "Import variables" = "Impor variabel" +"Imported document" = "Dokumen yang diimpor" "Imported {name}" = "{name} diimpor" "Imported {n} new variables, updated {updated}" = { other = "{n} variabel baru diimpor, {updated} diperbarui" } "Import…" = "Impor…" @@ -805,6 +810,7 @@ status = "draft — needs native review" "No colour variables fit here." = "Tidak ada variabel warna yang cocok di sini." "No colour variables yet. Create them in Local variables." = "Belum ada variabel warna. Buat di Variabel lokal." "No comments yet" = "Belum ada komentar" +"No compatibility warnings reported" = "Tidak ada peringatan kompatibilitas" "No components yet. Select layers and press {keys}." = "Belum ada component. Pilih layer lalu tekan {keys}." "No conflicts." = "Tidak ada konflik." "No contributor data was built into this copy." = "Tidak ada data kontributor yang disertakan dalam salinan ini." @@ -896,6 +902,7 @@ status = "draft — needs native review" "Open overlay" = "Buka overlay" "Open prototype preview" = "Buka pratinjau prototype" "Open variables ({count})" = "Buka variabel ({count})" +"Opened as a new document" = "Dibuka sebagai dokumen baru" "Opened “{name}” in a new tab" = "“{name}” dibuka di tab baru" "Open|comments" = "Terbuka" "Options" = "Opsi" @@ -920,6 +927,8 @@ status = "draft — needs native review" "Padding" = "Padding" "Page" = "Halaman" "Pages" = "Halaman" +"Pages added to the document" = "Halaman ditambahkan ke dokumen" +"Placed on the current page" = "Ditempatkan di halaman saat ini" "Pages: {pages}" = "Halaman: {pages}" "Paint bucket" = "Ember cat" "Pan the canvas" = "Geser kanvas" @@ -1489,26 +1498,90 @@ status = "draft — needs native review" "call any command its access allows" = "memanggil perintah apa pun yang diizinkan aksesnya" "call only: {commands}" = "hanya memanggil: {commands}" "can edit the document" = "dapat mengedit dokumen" +"conditional prototype actions nested too deeply were skipped" = "tindakan prototype kondisional yang bersarang terlalu dalam dilewati" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "tindakan prototype kondisional dengan kondisi yang tidak dapat dievaluasi dilewati" +"connector labels were skipped" = "label konektor dilewati" +"connectors whose ends can't be placed are empty" = "konektor yang ujungnya tidak dapat ditempatkan kosong" "crop marks" = "tanda potong" +"damaged vector geometry was skipped" = "geometri vector yang rusak dilewati" +"damaged vector networks import as outlines" = "jaringan vector yang rusak diimpor sebagai outline" "development: {path}" = "pengembangan: {path}" +"document children other than pages were skipped" = "anak dokumen selain halaman dilewati" "e.g. count + 1" = "mis. count + 1" "e.g. count >= 3 and not done" = "mis. count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "konektor siku dan lengkung tanpa jalur tersimpan diimpor sebagai garis lurus" +"entries without a node type were skipped" = "entri tanpa tipe node dilewati" "figcraft:// links from a browser, chat or email open this app and join the session." = "Link figcraft:// dari browser, chat, atau email membuka aplikasi ini dan bergabung ke sesi." +"filled areas of a vector with their own fill use the layer's fill" = "area terisi pada vector dengan isian sendiri memakai isian layer" "from {server}" = "dari {server}" +"gamepad triggers without exactly one known controller button were skipped" = "pemicu gamepad yang tidak memiliki tepat satu tombol kontroler yang dikenal dilewati" +"grid tracks of fixed or content size import as equal tracks" = "track grid berukuran tetap atau mengikuti konten diimpor sebagai track yang sama besar" "hidden" = "tersembunyi" "iOS" = "iOS" +"instance swap properties whose default component isn't in the file were skipped" = "properti tukar instance yang component bawaannya tidak ada di file dilewati" +"instances inside their own main component were imported as frames" = "instance di dalam main component-nya sendiri diimpor sebagai frame" +"instances of components that aren't in the file show no layers" = "instance dari component yang tidak ada di file tidak menampilkan layer" +"instances of components that aren't in the file were imported as frames" = "instance dari component yang tidak ada di file diimpor sebagai frame" +"instances that would nest a component inside itself were imported as frames" = "instance yang akan menyarangkan component di dalam dirinya sendiri diimpor sebagai frame" +"instances without a main component were imported as empty frames" = "instance tanpa main component diimpor sebagai frame kosong" +"invalid or oversized vector networks import as outlines" = "jaringan vector yang tidak valid atau terlalu besar diimpor sebagai outline" +"invalid or oversized vector path data was skipped" = "data jalur vector yang tidak valid atau terlalu besar dilewati" "just now" = "baru saja" +"key triggers without a supported key were skipped" = "pemicu tombol tanpa tombol yang didukung dilewati" +"key up triggers import as key presses" = "pemicu saat tombol dilepas diimpor sebagai penekanan tombol" +"layers without a position were placed at their parent's origin" = "layer tanpa posisi ditempatkan di titik asal induknya" +"layers without a type were skipped" = "layer tanpa tipe dilewati" "locked" = "terkunci" "no bleed" = "tanpa bleed" "nobody else here yet" = "belum ada orang lain di sini" "now" = "sekarang" "or" = "atau" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "sambungan stroke per titik dipertahankan; stroke putus-putus atau berlebar variabel digambar dengan sambungan layer" +"prototype actions of unknown types were skipped" = "tindakan prototype dengan tipe yang tidak dikenal dilewati" +"prototype actions without a destination in the file were skipped" = "tindakan prototype tanpa tujuan di file dilewati" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "ekspresi prototype pada variabel yang tidak ada di JSON variabel dilewati" +"prototype expressions with unsupported functions were skipped" = "ekspresi prototype dengan fungsi yang tidak didukung dilewati" +"prototype interactions without supported actions were skipped" = "interaksi prototype tanpa tindakan yang didukung dilewati" +"prototype navigations of unknown types were skipped" = "navigasi prototype dengan tipe yang tidak dikenal dilewati" +"prototype transitions of unknown types play instantly" = "transisi prototype dengan tipe yang tidak dikenal berjalan instan" +"prototype triggers of unknown types were skipped" = "pemicu prototype dengan tipe yang tidak dikenal dilewati" "put a widget on the canvas" = "menaruh widget di kanvas" "read and edit the document" = "membaca dan mengedit dokumen" "read the document" = "membaca dokumen" "reads the document" = "membaca dokumen" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "tindakan atur variabel pada variabel yang tidak ada di JSON variabel dilewati" +"set-variable actions with values that can't be evaluated were skipped" = "tindakan atur variabel dengan nilai yang tidak dapat dievaluasi dilewati" +"set-variable actions with values that don't fit the variable were skipped" = "tindakan atur variabel dengan nilai yang tidak cocok dengan variabelnya dilewati" +"set-variable actions without a value were skipped" = "tindakan atur variabel tanpa nilai dilewati" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "tindakan atur mode variabel pada koleksi atau mode yang tidak ada di JSON variabel dilewati" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "beberapa titik vector merujuk ke pengaturan titik yang tidak ada di file; titik tersebut memakai sambungan layer dan sudut biasa" +"stars and polygons without point counts or geometry use the default point count" = "bintang dan poligon tanpa jumlah titik atau geometri memakai jumlah titik bawaan" +"text style overrides refer to missing table entries" = "penimpaan gaya teks merujuk ke entri tabel yang tidak ada" +"the file has no pages" = "file tidak memiliki halaman" "the plugin" = "plugin" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "JSON variabel tidak memiliki `variableCollections` (yang diharapkan adalah respons variabel lokal) dan diabaikan" "this session only" = "hanya sesi ini" +"unknown component property types were skipped" = "tipe properti component yang tidak dikenal dilewati" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "alias ke variabel yang tidak ada di JSON variabel (atau bertipe lain) dilewati" +"variable bindings to properties the layer doesn't have were skipped" = "binding variabel ke properti yang tidak dimiliki layer dilewati" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "binding ke variabel yang tidak ada di JSON variabel (variabel pustaka) dilewati" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "binding variabel dilewati: JSON file tidak memiliki definisi variabel (berikan JSON variabel lokal sebagai `variables`, atau sematkan `meta`-nya di samping JSON file)" +"variable bindings whose variable type doesn't fit the property were skipped" = "binding variabel yang tipe variabelnya tidak cocok dengan properti dilewati" +"variable collections without modes were skipped" = "koleksi variabel tanpa mode dilewati" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "pencarian mode variabel dalam ekspresi prototype yang bukan berupa variabel dan salah satu modenya dilewati" +"variable modes of collections that aren't in the file were skipped" = "mode variabel dari koleksi yang tidak ada di file dilewati" +"variable modes of collections that aren't in the variables JSON were skipped" = "mode variabel dari koleksi yang tidak ada di JSON variabel dilewati" +"variable values that don't match their type (or composed colours) were skipped" = "nilai variabel yang tidak sesuai dengan tipenya (atau warna gabungan) dilewati" +"variables of unknown types were skipped" = "variabel dengan tipe yang tidak dikenal dilewati" +"variables whose collection isn't in the variables JSON were skipped" = "variabel yang koleksinya tidak ada di JSON variabel dilewati" +"variables without readable values were skipped" = "variabel tanpa nilai yang dapat dibaca dilewati" +"vector points with unknown stroke joins use the layer join" = "titik vector dengan sambungan stroke yang tidak dikenal memakai sambungan layer" +"vectors without geometry are empty" = "vector tanpa geometri kosong" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "vector tanpa geometri kosong (ekspor JSON file dengan geometry=paths)" +"very long text was cut to 1,048,576 characters" = "teks yang sangat panjang dipotong menjadi 1.048.576 karakter" +"very long vector paths were skipped" = "jalur vector yang sangat panjang dilewati" +"video actions are not supported and were skipped" = "tindakan video tidak didukung dan dilewati" +"video triggers are not supported and were skipped" = "pemicu video tidak didukung dan dilewati" "yesterday" = "kemarin" "{instances} in {files}" = "{instances} di {files}" "{language}: draft translation, waiting for a native speaker's review." = "{language}: terjemahan draf, menunggu tinjauan penutur asli." @@ -1553,6 +1626,7 @@ status = "draft — needs native review" "{n} matches" = { other = "{n} kecocokan" } "{n} minutes ago" = { other = "{n} menit lalu" } "{n} months ago" = { other = "{n} bulan lalu" } +"{n} more notes omitted from this report" = { other = "{n} catatan lainnya tidak dicantumkan dalam laporan ini" } "{n} of {total}" = "{n} dari {total}" "{n} people joined" = { other = "{n} orang bergabung" } "{n} preferred components" = { other = "{n} component pilihan" } diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 4d4d6d34..c337eb38 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -272,6 +272,7 @@ status = "draft — needs native review" "Commits" = "コミット" "Company" = "会社" "Compare changes" = "変更を比較" +"Compatibility notes" = "互換性に関する注記" "Completed" = "完了" "Component" = "コンポーネント" "Component name" = "コンポーネント名" @@ -480,6 +481,7 @@ status = "draft — needs native review" "Feb" = "2月" "Fetching from the server…" = "サーバーから取得中…" "FigCraft couldn't open its window: {error}" = "FigCraft のウィンドウを開けなかった:{error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft はこれらのファイルの機能をまだすべてサポートしていない。注記には、インポートしたコピーで省略または変更された内容を示す。元のファイルは変更されない。" "File" = "ファイル" "File name" = "ファイル名" "File not found" = "ファイルが見つからない" @@ -604,11 +606,14 @@ status = "draft — needs native review" "If {condition}" = "条件:{condition}" "Ignore auto layout (absolute position)" = "オートレイアウトを無視(絶対位置)" "Image" = "画像" +"Images" = "画像" "Import" = "インポート" "Import path" = "インポートパス" "Import plugin from manifest" = "マニフェストからプラグインをインポート" "Import plugin from manifest…" = "マニフェストからプラグインをインポート…" +"Import report" = "インポートレポート" "Import variables" = "変数をインポート" +"Imported document" = "インポートしたドキュメント" "Imported {name}" = "{name} をインポート済み" "Imported {n} new variables, updated {updated}" = { other = "新しい変数を {n} 個インポートし、{updated} 個を更新" } "Import…" = "インポート…" @@ -810,6 +815,7 @@ status = "draft — needs native review" "No colour variables fit here." = "ここに使えるカラー変数はない。" "No colour variables yet. Create them in Local variables." = "カラー変数はまだない。ローカル変数で作成。" "No comments yet" = "コメントはまだない" +"No compatibility warnings reported" = "互換性の警告はない" "No components yet. Select layers and press {keys}." = "コンポーネントはまだない。レイヤーを選択して {keys} を押す。" "No conflicts." = "競合なし。" "No contributor data was built into this copy." = "このコピーには貢献者データが含まれていない。" @@ -901,6 +907,7 @@ status = "draft — needs native review" "Open overlay" = "オーバーレイを開く" "Open prototype preview" = "プロトタイプのプレビューを開く" "Open variables ({count})" = "変数を開く({count})" +"Opened as a new document" = "新しいドキュメントとして開いた" "Opened “{name}” in a new tab" = "「{name}」を新しいタブで開いた" "Open|comments" = "未解決" "Options" = "オプション" @@ -925,6 +932,8 @@ status = "draft — needs native review" "Padding" = "パディング" "Page" = "ページ" "Pages" = "ページ" +"Pages added to the document" = "ページをドキュメントに追加した" +"Placed on the current page" = "現在のページに配置した" "Pages: {pages}" = "ページ:{pages}" "Paint bucket" = "塗りつぶし" "Pan the canvas" = "キャンバスをパン" @@ -1499,26 +1508,90 @@ status = "draft — needs native review" "call any command its access allows" = "アクセス権で許可された任意のコマンドの呼び出し" "call only: {commands}" = "次のコマンドのみの呼び出し:{commands}" "can edit the document" = "ドキュメントを編集できる" +"conditional prototype actions nested too deeply were skipped" = "入れ子が深すぎる条件付きプロトタイプアクションをスキップ" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "評価できない条件を持つ条件付きプロトタイプアクションをスキップ" +"connector labels were skipped" = "コネクタのラベルをスキップ" +"connectors whose ends can't be placed are empty" = "端点を配置できないコネクタは空" "crop marks" = "トンボ" +"damaged vector geometry was skipped" = "破損したベクタージオメトリをスキップ" +"damaged vector networks import as outlines" = "破損したベクターネットワークはアウトラインとしてインポート" "development: {path}" = "開発:{path}" +"document children other than pages were skipped" = "ページ以外のドキュメントの子をスキップ" "e.g. count + 1" = "例:count + 1" "e.g. count >= 3 and not done" = "例:count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "保存されたパスのない折れ線・曲線コネクタは直線としてインポート" +"entries without a node type were skipped" = "ノードタイプのない項目をスキップ" "figcraft:// links from a browser, chat or email open this app and join the session." = "ブラウザー、チャット、メールからの figcraft:// リンクでこのアプリが開き、セッションに参加する。" +"filled areas of a vector with their own fill use the layer's fill" = "独自の塗りを持つベクターの塗りつぶし領域にはレイヤーの塗りを使用" "from {server}" = "{server} から" +"gamepad triggers without exactly one known controller button were skipped" = "既知のコントローラーボタンがちょうど 1 つではないゲームパッドトリガーをスキップ" +"grid tracks of fixed or content size import as equal tracks" = "固定サイズまたはコンテンツサイズのグリッドトラックは均等なトラックとしてインポート" "hidden" = "非表示" "iOS" = "iOS" +"instance swap properties whose default component isn't in the file were skipped" = "デフォルトのコンポーネントがファイルにないインスタンスの入れ替えプロパティをスキップ" +"instances inside their own main component were imported as frames" = "自身のメインコンポーネント内にあるインスタンスはフレームとしてインポート" +"instances of components that aren't in the file show no layers" = "ファイルにないコンポーネントのインスタンスにはレイヤーが表示されない" +"instances of components that aren't in the file were imported as frames" = "ファイルにないコンポーネントのインスタンスはフレームとしてインポート" +"instances that would nest a component inside itself were imported as frames" = "コンポーネントを自身の中に入れ子にしてしまうインスタンスはフレームとしてインポート" +"instances without a main component were imported as empty frames" = "メインコンポーネントのないインスタンスは空のフレームとしてインポート" +"invalid or oversized vector networks import as outlines" = "無効または大きすぎるベクターネットワークはアウトラインとしてインポート" +"invalid or oversized vector path data was skipped" = "無効または大きすぎるベクターパスデータをスキップ" "just now" = "たった今" +"key triggers without a supported key were skipped" = "サポートされているキーのないキートリガーをスキップ" +"key up triggers import as key presses" = "キーを離すトリガーはキー押下としてインポート" +"layers without a position were placed at their parent's origin" = "位置のないレイヤーは親の原点に配置" +"layers without a type were skipped" = "タイプのないレイヤーをスキップ" "locked" = "ロック中" "no bleed" = "裁ち落としなし" "nobody else here yet" = "まだ他に誰もいない" "now" = "たった今" "or" = "または" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "ポイントごとの角の形状は保持した。破線や可変幅の線はレイヤーの角の形状で描画" +"prototype actions of unknown types were skipped" = "不明なタイプのプロトタイプアクションをスキップ" +"prototype actions without a destination in the file were skipped" = "ファイル内に移動先のないプロトタイプアクションをスキップ" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "変数 JSON にない変数を使うプロトタイプの式をスキップ" +"prototype expressions with unsupported functions were skipped" = "サポートされていない関数を使うプロトタイプの式をスキップ" +"prototype interactions without supported actions were skipped" = "サポートされているアクションのないプロトタイプインタラクションをスキップ" +"prototype navigations of unknown types were skipped" = "不明なタイプのプロトタイプナビゲーションをスキップ" +"prototype transitions of unknown types play instantly" = "不明なタイプのプロトタイプトランジションは即時に再生" +"prototype triggers of unknown types were skipped" = "不明なタイプのプロトタイプトリガーをスキップ" "put a widget on the canvas" = "キャンバスへのウィジェットの配置" "read and edit the document" = "ドキュメントの読み取りと編集" "read the document" = "ドキュメントの読み取り" "reads the document" = "ドキュメントを読み取る" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "変数 JSON にない変数に対する変数設定アクションをスキップ" +"set-variable actions with values that can't be evaluated were skipped" = "評価できない値を持つ変数設定アクションをスキップ" +"set-variable actions with values that don't fit the variable were skipped" = "変数に合わない値を持つ変数設定アクションをスキップ" +"set-variable actions without a value were skipped" = "値のない変数設定アクションをスキップ" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "変数 JSON にないコレクションまたはモードに対する変数モード設定アクションをスキップ" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "一部のベクターポイントがファイルにないポイント設定を参照している。それらのポイントはレイヤーの角の形状と丸めのない角を使用" +"stars and polygons without point counts or geometry use the default point count" = "ポイント数もジオメトリもない星と多角形はデフォルトのポイント数を使用" +"text style overrides refer to missing table entries" = "テキストスタイルのオーバーライドが存在しないテーブル項目を参照している" +"the file has no pages" = "ファイルにページがない" "the plugin" = "このプラグイン" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "変数 JSON に `variableCollections` がない(ローカル変数のレスポンスを想定)ため無視した" "this session only" = "このセッションのみ" +"unknown component property types were skipped" = "不明なタイプのコンポーネントプロパティをスキップ" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "変数 JSON にない(または型が異なる)変数へのエイリアスをスキップ" +"variable bindings to properties the layer doesn't have were skipped" = "レイヤーにないプロパティへの変数バインディングをスキップ" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "変数 JSON にない変数(ライブラリ変数)へのバインディングをスキップ" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "変数バインディングをスキップ:ファイル JSON に変数の定義がない(ローカル変数 JSON を `variables` として渡すか、その `meta` をファイル JSON の隣に埋め込む)" +"variable bindings whose variable type doesn't fit the property were skipped" = "変数の型がプロパティに合わない変数バインディングをスキップ" +"variable collections without modes were skipped" = "モードのない変数コレクションをスキップ" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "変数とそのモードの 1 つになっていない、プロトタイプの式内の変数モード参照をスキップ" +"variable modes of collections that aren't in the file were skipped" = "ファイルにないコレクションの変数モードをスキップ" +"variable modes of collections that aren't in the variables JSON were skipped" = "変数 JSON にないコレクションの変数モードをスキップ" +"variable values that don't match their type (or composed colours) were skipped" = "型に合わない変数値(または合成カラー)をスキップ" +"variables of unknown types were skipped" = "不明なタイプの変数をスキップ" +"variables whose collection isn't in the variables JSON were skipped" = "コレクションが変数 JSON にない変数をスキップ" +"variables without readable values were skipped" = "読み取れる値のない変数をスキップ" +"vector points with unknown stroke joins use the layer join" = "不明な角の形状を持つベクターポイントはレイヤーの角の形状を使用" +"vectors without geometry are empty" = "ジオメトリのないベクターは空" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "ジオメトリのないベクターは空(geometry=paths を指定してファイル JSON を書き出す)" +"very long text was cut to 1,048,576 characters" = "非常に長いテキストを 1,048,576 文字に切り詰めた" +"very long vector paths were skipped" = "非常に長いベクターパスをスキップ" +"video actions are not supported and were skipped" = "ビデオアクションはサポートされていないためスキップ" +"video triggers are not supported and were skipped" = "ビデオトリガーはサポートされていないためスキップ" "yesterday" = "昨日" "{instances} in {files}" = "{files}内の{instances}" "{language}: draft translation, waiting for a native speaker's review." = "{language}:下書きの翻訳。ネイティブスピーカーによるレビュー待ち。" @@ -1563,6 +1636,7 @@ status = "draft — needs native review" "{n} matches" = { other = "{n} 件一致" } "{n} minutes ago" = { other = "{n} 分前" } "{n} months ago" = { other = "{n} か月前" } +"{n} more notes omitted from this report" = { other = "このレポートで省略した注記がさらに {n} 件" } "{n} of {total}" = "{n} / {total}" "{n} people joined" = { other = "{n} 人が参加済み" } "{n} preferred components" = { other = "優先コンポーネント {n} 個" } diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index ec732905..58192efb 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -261,6 +261,7 @@ status = "draft — needs native review" "Commits" = "커밋" "Company" = "회사" "Compare changes" = "변경 사항 비교" +"Compatibility notes" = "호환성 참고 사항" "Completed" = "완료됨" "Component" = "컴포넌트" "Component name" = "컴포넌트 이름" @@ -460,6 +461,7 @@ status = "draft — needs native review" "False" = "거짓" "Fetching from the server…" = "서버에서 가져오는 중…" "FigCraft couldn't open its window: {error}" = "FigCraft 창을 열 수 없음: {error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft는 아직 이 파일의 모든 기능을 지원하지 않습니다. 참고 사항에는 가져온 사본에서 빠지거나 바뀐 내용이 나와 있으며, 원본 파일은 바뀌지 않습니다." "File" = "파일" "File name" = "파일 이름" "File published" = "파일 게시됨" @@ -576,11 +578,14 @@ status = "draft — needs native review" "If {condition}" = "조건: {condition}" "Ignore auto layout (absolute position)" = "자동 레이아웃 무시(절대 위치)" "Image" = "이미지" +"Images" = "이미지" "Import" = "가져오기" "Import path" = "가져오기 경로" "Import plugin from manifest" = "매니페스트에서 플러그인 가져오기" "Import plugin from manifest…" = "매니페스트에서 플러그인 가져오기…" +"Import report" = "가져오기 보고서" "Import variables" = "변수 가져오기" +"Imported document" = "가져온 문서" "Imported {name}" = "{name} 가져옴" "Imported {n} new variables, updated {updated}" = { other = "새 변수 {n}개를 가져오고 {updated}개를 업데이트함" } "Import…" = "가져오기…" @@ -770,6 +775,7 @@ status = "draft — needs native review" "No colour variables fit here." = "여기에 맞는 색상 변수가 없음." "No colour variables yet. Create them in Local variables." = "아직 색상 변수가 없음. 로컬 변수에서 만드세요." "No comments yet" = "아직 댓글 없음" +"No compatibility warnings reported" = "보고된 호환성 경고 없음" "No components yet. Select layers and press {keys}." = "아직 컴포넌트가 없음. 레이어를 선택하고 {keys}를 누르세요." "No conflicts." = "충돌 없음." "No contributor data was built into this copy." = "이 사본에는 기여자 데이터가 포함되지 않음." @@ -856,6 +862,7 @@ status = "draft — needs native review" "Open overlay" = "오버레이 열기" "Open prototype preview" = "프로토타입 미리 보기 열기" "Open variables ({count})" = "변수 열기({count})" +"Opened as a new document" = "새 문서로 열림" "Opened “{name}” in a new tab" = "\"{name}\"을(를) 새 탭에서 열었음" "Open|comments" = "미해결" "Options" = "옵션" @@ -879,6 +886,8 @@ status = "draft — needs native review" "Padding" = "패딩" "Page" = "페이지" "Pages" = "페이지" +"Pages added to the document" = "문서에 페이지 추가됨" +"Placed on the current page" = "현재 페이지에 배치됨" "Pages: {pages}" = "페이지: {pages}" "Paint bucket" = "페인트 통" "Pan the canvas" = "캔버스 이동" @@ -1426,25 +1435,89 @@ status = "draft — needs native review" "call any command its access allows" = "접근 권한이 허용하는 모든 명령 호출" "call only: {commands}" = "다음 명령만 호출: {commands}" "can edit the document" = "문서를 편집할 수 있음" +"conditional prototype actions nested too deeply were skipped" = "너무 깊게 중첩된 조건부 프로토타입 액션을 건너뜀" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "평가할 수 없는 조건이 있는 조건부 프로토타입 액션을 건너뜀" +"connector labels were skipped" = "커넥터 레이블을 건너뜀" +"connectors whose ends can't be placed are empty" = "끝점을 배치할 수 없는 커넥터는 비어 있음" "crop marks" = "재단선 표시" +"damaged vector geometry was skipped" = "손상된 벡터 지오메트리를 건너뜀" +"damaged vector networks import as outlines" = "손상된 벡터 네트워크는 윤곽선으로 가져옴" "development: {path}" = "개발: {path}" +"document children other than pages were skipped" = "페이지가 아닌 문서 하위 항목을 건너뜀" "e.g. count + 1" = "예: count + 1" "e.g. count >= 3 and not done" = "예: count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "저장된 경로가 없는 꺾인 커넥터와 곡선 커넥터는 직선으로 가져옴" +"entries without a node type were skipped" = "노드 유형이 없는 항목을 건너뜀" "figcraft:// links from a browser, chat or email open this app and join the session." = "브라우저, 채팅, 이메일의 figcraft:// 링크로 이 앱을 열고 세션에 참여함." +"filled areas of a vector with their own fill use the layer's fill" = "자체 채우기가 있는 벡터의 채워진 영역은 레이어의 채우기를 사용함" "from {server}" = "{server}에서" +"gamepad triggers without exactly one known controller button were skipped" = "알려진 컨트롤러 버튼이 정확히 하나가 아닌 게임패드 트리거를 건너뜀" +"grid tracks of fixed or content size import as equal tracks" = "고정 크기 또는 콘텐츠 크기의 그리드 트랙은 균등한 트랙으로 가져옴" "hidden" = "숨김" "iOS" = "iOS" +"instance swap properties whose default component isn't in the file were skipped" = "기본 컴포넌트가 파일에 없는 인스턴스 교체 속성을 건너뜀" +"instances inside their own main component were imported as frames" = "자체 메인 컴포넌트 안에 있는 인스턴스는 프레임으로 가져옴" +"instances of components that aren't in the file show no layers" = "파일에 없는 컴포넌트의 인스턴스에는 레이어가 표시되지 않음" +"instances of components that aren't in the file were imported as frames" = "파일에 없는 컴포넌트의 인스턴스는 프레임으로 가져옴" +"instances that would nest a component inside itself were imported as frames" = "컴포넌트를 자기 안에 중첩하게 되는 인스턴스는 프레임으로 가져옴" +"instances without a main component were imported as empty frames" = "메인 컴포넌트가 없는 인스턴스는 빈 프레임으로 가져옴" +"invalid or oversized vector networks import as outlines" = "잘못되었거나 너무 큰 벡터 네트워크는 윤곽선으로 가져옴" +"invalid or oversized vector path data was skipped" = "잘못되었거나 너무 큰 벡터 경로 데이터를 건너뜀" +"key triggers without a supported key were skipped" = "지원되는 키가 없는 키 트리거를 건너뜀" +"key up triggers import as key presses" = "키를 뗄 때 트리거는 키 누름으로 가져옴" +"layers without a position were placed at their parent's origin" = "위치가 없는 레이어는 상위 요소의 원점에 배치됨" +"layers without a type were skipped" = "유형이 없는 레이어를 건너뜀" "locked" = "잠김" "no bleed" = "도련 없음" "nobody else here yet" = "아직 다른 사람이 없음" "now" = "방금" "or" = "또는" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "점별 선 연결을 유지함. 파선이나 가변 너비 선은 레이어의 선 연결로 그려짐" +"prototype actions of unknown types were skipped" = "알 수 없는 유형의 프로토타입 액션을 건너뜀" +"prototype actions without a destination in the file were skipped" = "파일에 대상이 없는 프로토타입 액션을 건너뜀" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "변수 JSON에 없는 변수에 대한 프로토타입 수식을 건너뜀" +"prototype expressions with unsupported functions were skipped" = "지원되지 않는 함수가 있는 프로토타입 수식을 건너뜀" +"prototype interactions without supported actions were skipped" = "지원되는 액션이 없는 프로토타입 인터랙션을 건너뜀" +"prototype navigations of unknown types were skipped" = "알 수 없는 유형의 프로토타입 탐색을 건너뜀" +"prototype transitions of unknown types play instantly" = "알 수 없는 유형의 프로토타입 전환은 즉시 재생됨" +"prototype triggers of unknown types were skipped" = "알 수 없는 유형의 프로토타입 트리거를 건너뜀" "put a widget on the canvas" = "캔버스에 위젯 배치" "read and edit the document" = "문서 읽기 및 편집" "read the document" = "문서 읽기" "reads the document" = "문서를 읽음" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "변수 JSON에 없는 변수에 대한 변수 설정 액션을 건너뜀" +"set-variable actions with values that can't be evaluated were skipped" = "평가할 수 없는 값이 있는 변수 설정 액션을 건너뜀" +"set-variable actions with values that don't fit the variable were skipped" = "변수에 맞지 않는 값이 있는 변수 설정 액션을 건너뜀" +"set-variable actions without a value were skipped" = "값이 없는 변수 설정 액션을 건너뜀" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "변수 JSON에 없는 컬렉션이나 모드에 대한 변수 모드 설정 액션을 건너뜀" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "일부 벡터 점이 파일에 없는 점 설정을 참조함. 해당 점은 레이어의 선 연결과 둥글리지 않은 모서리를 사용함" +"stars and polygons without point counts or geometry use the default point count" = "점 개수나 지오메트리가 없는 별과 다각형은 기본 점 개수를 사용함" +"text style overrides refer to missing table entries" = "텍스트 스타일 재정의가 없는 테이블 항목을 참조함" +"the file has no pages" = "파일에 페이지가 없음" "the plugin" = "이 플러그인" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "변수 JSON에 `variableCollections`가 없어(로컬 변수 응답이어야 함) 무시함" "this session only" = "이 세션만" +"unknown component property types were skipped" = "알 수 없는 컴포넌트 속성 유형을 건너뜀" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "변수 JSON에 없거나 유형이 다른 변수에 대한 별칭을 건너뜀" +"variable bindings to properties the layer doesn't have were skipped" = "레이어에 없는 속성에 대한 변수 바인딩을 건너뜀" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "변수 JSON에 없는 변수(라이브러리 변수)에 대한 바인딩을 건너뜀" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "변수 바인딩을 건너뜀: 파일 JSON에 변수 정의가 없음(로컬 변수 JSON을 `variables`로 전달하거나 파일 JSON 옆에 해당 `meta`를 포함하세요)" +"variable bindings whose variable type doesn't fit the property were skipped" = "변수 유형이 속성에 맞지 않는 변수 바인딩을 건너뜀" +"variable collections without modes were skipped" = "모드가 없는 변수 컬렉션을 건너뜀" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "변수와 그 모드 중 하나가 아닌, 프로토타입 수식의 변수 모드 조회를 건너뜀" +"variable modes of collections that aren't in the file were skipped" = "파일에 없는 컬렉션의 변수 모드를 건너뜀" +"variable modes of collections that aren't in the variables JSON were skipped" = "변수 JSON에 없는 컬렉션의 변수 모드를 건너뜀" +"variable values that don't match their type (or composed colours) were skipped" = "유형과 맞지 않는 변수 값(또는 합성 색상)을 건너뜀" +"variables of unknown types were skipped" = "알 수 없는 유형의 변수를 건너뜀" +"variables whose collection isn't in the variables JSON were skipped" = "컬렉션이 변수 JSON에 없는 변수를 건너뜀" +"variables without readable values were skipped" = "읽을 수 있는 값이 없는 변수를 건너뜀" +"vector points with unknown stroke joins use the layer join" = "알 수 없는 선 연결이 있는 벡터 점은 레이어의 선 연결을 사용함" +"vectors without geometry are empty" = "지오메트리가 없는 벡터는 비어 있음" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "지오메트리가 없는 벡터는 비어 있음(geometry=paths로 파일 JSON을 내보내세요)" +"very long text was cut to 1,048,576 characters" = "매우 긴 텍스트를 1,048,576자로 잘랐음" +"very long vector paths were skipped" = "매우 긴 벡터 경로를 건너뜀" +"video actions are not supported and were skipped" = "비디오 액션은 지원되지 않아 건너뜀" +"video triggers are not supported and were skipped" = "비디오 트리거는 지원되지 않아 건너뜀" "{instances} in {files}" = "{files}의 {instances}" "{language}: draft translation, waiting for a native speaker's review." = "{language}: 초안 번역, 원어민 검토 대기 중." "{length} bleed" = "도련 {length}" @@ -1487,6 +1560,7 @@ status = "draft — needs native review" "{n} matches" = { other = "{n}개 일치" } "{n} minutes ago" = { other = "{n}분 전" } "{n} months ago" = { other = "{n}개월 전" } +"{n} more notes omitted from this report" = { other = "참고 사항 {n}개가 이 보고서에서 더 생략됨" } "{n} of {total}" = "{n} / {total}" "{n} people joined" = { other = "{n}명 참여함" } "{n} preferred components" = { other = "선호 컴포넌트 {n}개" } diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index c2189de8..a6f53dc8 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -259,6 +259,7 @@ status = "draft — needs native review" "Commits" = "Commits" "Company" = "Empresa" "Compare changes" = "Comparar alterações" +"Compatibility notes" = "Notas de compatibilidade" "Completed" = "Concluído" "Component" = "Componente" "Component name" = "Nome do componente" @@ -459,6 +460,7 @@ status = "draft — needs native review" "False" = "Falso" "Fetching from the server…" = "Buscando no servidor…" "FigCraft couldn't open its window: {error}" = "O FigCraft não conseguiu abrir a janela: {error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "O FigCraft ainda não oferece suporte a todos os recursos desses arquivos. As notas listam o que foi omitido ou alterado na cópia importada; o arquivo original não é alterado." "File" = "Arquivo" "File name" = "Nome do arquivo" "File published" = "Arquivo publicado" @@ -575,11 +577,14 @@ status = "draft — needs native review" "If {condition}" = "Se {condition}" "Ignore auto layout (absolute position)" = "Ignorar layout automático (posição absoluta)" "Image" = "Imagem" +"Images" = "Imagens" "Import" = "Importar" "Import path" = "Caminho de importação" "Import plugin from manifest" = "Importar plugin a partir do manifesto" "Import plugin from manifest…" = "Importar plugin a partir do manifesto…" +"Import report" = "Relatório de importação" "Import variables" = "Importar variáveis" +"Imported document" = "Documento importado" "Imported {name}" = "{name} importado" "Imported {n} new variables, updated {updated}" = { one = "{n} variável nova importada, {updated} atualizadas", other = "{n} variáveis novas importadas, {updated} atualizadas" } "Import…" = "Importar…" @@ -769,6 +774,7 @@ status = "draft — needs native review" "No colour variables fit here." = "Nenhuma variável de cor se encaixa aqui." "No colour variables yet. Create them in Local variables." = "Ainda não há variáveis de cor. Crie-as em Variáveis locais." "No comments yet" = "Ainda não há comentários" +"No compatibility warnings reported" = "Nenhum aviso de compatibilidade relatado" "No components yet. Select layers and press {keys}." = "Ainda não há componentes. Selecione camadas e pressione {keys}." "No conflicts." = "Nenhum conflito." "No contributor data was built into this copy." = "Esta cópia não inclui dados de colaboradores." @@ -855,6 +861,7 @@ status = "draft — needs native review" "Open overlay" = "Abrir sobreposição" "Open prototype preview" = "Abrir visualização do protótipo" "Open variables ({count})" = "Abrir variáveis ({count})" +"Opened as a new document" = "Aberto como novo documento" "Opened “{name}” in a new tab" = "“{name}” aberto em uma nova aba" "Open|comments" = "Abertos" "Options" = "Opções" @@ -878,6 +885,8 @@ status = "draft — needs native review" "Padding" = "Espaçamento interno" "Page" = "Página" "Pages" = "Páginas" +"Pages added to the document" = "Páginas adicionadas ao documento" +"Placed on the current page" = "Colocado na página atual" "Pages: {pages}" = "Páginas: {pages}" "Paint bucket" = "Balde de tinta" "Pan the canvas" = "Mover a tela" @@ -1425,25 +1434,89 @@ status = "draft — needs native review" "call any command its access allows" = "chamar qualquer comando que seu acesso permita" "call only: {commands}" = "chamar apenas: {commands}" "can edit the document" = "pode editar o documento" +"conditional prototype actions nested too deeply were skipped" = "ações condicionais de protótipo aninhadas profundamente demais foram ignoradas" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "ações condicionais de protótipo com condições que não podem ser avaliadas foram ignoradas" +"connector labels were skipped" = "rótulos de conectores foram ignorados" +"connectors whose ends can't be placed are empty" = "conectores cujas pontas não podem ser posicionadas ficam vazios" "crop marks" = "marcas de corte" +"damaged vector geometry was skipped" = "geometria vetorial danificada foi ignorada" +"damaged vector networks import as outlines" = "redes vetoriais danificadas são importadas como contornos" "development: {path}" = "desenvolvimento: {path}" +"document children other than pages were skipped" = "filhos do documento que não são páginas foram ignorados" "e.g. count + 1" = "por ex. count + 1" "e.g. count >= 3 and not done" = "por ex. count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "conectores angulares e curvos sem um caminho salvo são importados como linhas retas" +"entries without a node type were skipped" = "entradas sem tipo de nó foram ignoradas" "figcraft:// links from a browser, chat or email open this app and join the session." = "Links figcraft:// de um navegador, chat ou e-mail abrem este app e entram na sessão." +"filled areas of a vector with their own fill use the layer's fill" = "áreas preenchidas de um vetor com preenchimento próprio usam o preenchimento da camada" "from {server}" = "de {server}" +"gamepad triggers without exactly one known controller button were skipped" = "gatilhos de gamepad sem exatamente um botão de controle conhecido foram ignorados" +"grid tracks of fixed or content size import as equal tracks" = "trilhas de grade de tamanho fixo ou pelo conteúdo são importadas como trilhas iguais" "hidden" = "oculta" "iOS" = "iOS" +"instance swap properties whose default component isn't in the file were skipped" = "propriedades de troca de instância cujo componente padrão não está no arquivo foram ignoradas" +"instances inside their own main component were imported as frames" = "instâncias dentro do próprio componente principal foram importadas como quadros" +"instances of components that aren't in the file show no layers" = "instâncias de componentes que não estão no arquivo não mostram camadas" +"instances of components that aren't in the file were imported as frames" = "instâncias de componentes que não estão no arquivo foram importadas como quadros" +"instances that would nest a component inside itself were imported as frames" = "instâncias que aninhariam um componente dentro dele mesmo foram importadas como quadros" +"instances without a main component were imported as empty frames" = "instâncias sem componente principal foram importadas como quadros vazios" +"invalid or oversized vector networks import as outlines" = "redes vetoriais inválidas ou grandes demais são importadas como contornos" +"invalid or oversized vector path data was skipped" = "dados de caminho vetorial inválidos ou grandes demais foram ignorados" +"key triggers without a supported key were skipped" = "gatilhos de tecla sem uma tecla compatível foram ignorados" +"key up triggers import as key presses" = "gatilhos ao soltar uma tecla são importados como pressionamentos de tecla" +"layers without a position were placed at their parent's origin" = "camadas sem posição foram colocadas na origem do elemento pai" +"layers without a type were skipped" = "camadas sem tipo foram ignoradas" "locked" = "bloqueada" "no bleed" = "sem sangria" "nobody else here yet" = "ainda não há mais ninguém" "now" = "agora" "or" = "ou" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "as junções de traço por ponto foram mantidas; traços tracejados ou de largura variável são desenhados com a junção da camada" +"prototype actions of unknown types were skipped" = "ações de protótipo de tipos desconhecidos foram ignoradas" +"prototype actions without a destination in the file were skipped" = "ações de protótipo sem destino no arquivo foram ignoradas" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "expressões de protótipo com variáveis que não estão no JSON de variáveis foram ignoradas" +"prototype expressions with unsupported functions were skipped" = "expressões de protótipo com funções sem suporte foram ignoradas" +"prototype interactions without supported actions were skipped" = "interações de protótipo sem ações compatíveis foram ignoradas" +"prototype navigations of unknown types were skipped" = "navegações de protótipo de tipos desconhecidos foram ignoradas" +"prototype transitions of unknown types play instantly" = "transições de protótipo de tipos desconhecidos são instantâneas" +"prototype triggers of unknown types were skipped" = "gatilhos de protótipo de tipos desconhecidos foram ignorados" "put a widget on the canvas" = "colocar um widget na tela" "read and edit the document" = "ler e editar o documento" "read the document" = "ler o documento" "reads the document" = "lê o documento" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "ações de definir variável em variáveis que não estão no JSON de variáveis foram ignoradas" +"set-variable actions with values that can't be evaluated were skipped" = "ações de definir variável com valores que não podem ser avaliados foram ignoradas" +"set-variable actions with values that don't fit the variable were skipped" = "ações de definir variável com valores que não cabem na variável foram ignoradas" +"set-variable actions without a value were skipped" = "ações de definir variável sem valor foram ignoradas" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "ações de definir modo de variável em coleções ou modos que não estão no JSON de variáveis foram ignoradas" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "alguns pontos vetoriais se referem a configurações de ponto ausentes no arquivo; eles usam as junções da camada e cantos simples" +"stars and polygons without point counts or geometry use the default point count" = "estrelas e polígonos sem número de pontas nem geometria usam o número de pontas padrão" +"text style overrides refer to missing table entries" = "substituições de estilo de texto se referem a entradas de tabela ausentes" +"the file has no pages" = "o arquivo não tem páginas" "the plugin" = "o plugin" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "o JSON de variáveis não tem `variableCollections` (era esperada a resposta de variáveis locais) e foi ignorado" "this session only" = "somente nesta sessão" +"unknown component property types were skipped" = "tipos desconhecidos de propriedade de componente foram ignorados" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "aliases para variáveis que não estão no JSON de variáveis (ou de outro tipo) foram ignorados" +"variable bindings to properties the layer doesn't have were skipped" = "vínculos de variáveis a propriedades que a camada não tem foram ignorados" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "vínculos a variáveis que não estão no JSON de variáveis (variáveis de biblioteca) foram ignorados" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "vínculos de variáveis foram ignorados: o JSON do arquivo não tem definições de variáveis (passe o JSON de variáveis locais como `variables` ou incorpore o `meta` dele ao lado do JSON do arquivo)" +"variable bindings whose variable type doesn't fit the property were skipped" = "vínculos de variáveis cujo tipo de variável não combina com a propriedade foram ignorados" +"variable collections without modes were skipped" = "coleções de variáveis sem modos foram ignoradas" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "consultas de modo de variável em expressões de protótipo que não são uma variável e um de seus modos foram ignoradas" +"variable modes of collections that aren't in the file were skipped" = "modos de variáveis de coleções que não estão no arquivo foram ignorados" +"variable modes of collections that aren't in the variables JSON were skipped" = "modos de variáveis de coleções que não estão no JSON de variáveis foram ignorados" +"variable values that don't match their type (or composed colours) were skipped" = "valores de variáveis que não correspondem ao seu tipo (ou cores compostas) foram ignorados" +"variables of unknown types were skipped" = "variáveis de tipos desconhecidos foram ignoradas" +"variables whose collection isn't in the variables JSON were skipped" = "variáveis cuja coleção não está no JSON de variáveis foram ignoradas" +"variables without readable values were skipped" = "variáveis sem valores legíveis foram ignoradas" +"vector points with unknown stroke joins use the layer join" = "pontos vetoriais com junções de traço desconhecidas usam a junção da camada" +"vectors without geometry are empty" = "vetores sem geometria ficam vazios" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "vetores sem geometria ficam vazios (exporte o JSON do arquivo com geometry=paths)" +"very long text was cut to 1,048,576 characters" = "textos muito longos foram cortados em 1.048.576 caracteres" +"very long vector paths were skipped" = "caminhos vetoriais muito longos foram ignorados" +"video actions are not supported and were skipped" = "ações de vídeo não têm suporte e foram ignoradas" +"video triggers are not supported and were skipped" = "gatilhos de vídeo não têm suporte e foram ignorados" "{instances} in {files}" = "{instances} em {files}" "{language}: draft translation, waiting for a native speaker's review." = "{language}: tradução em rascunho, aguardando revisão de um falante nativo." "{length} bleed" = "sangria de {length}" @@ -1486,6 +1559,7 @@ status = "draft — needs native review" "{n} matches" = { one = "{n} resultado", other = "{n} resultados" } "{n} minutes ago" = { one = "há {n} minuto", other = "há {n} minutos" } "{n} months ago" = { one = "há {n} mês", other = "há {n} meses" } +"{n} more notes omitted from this report" = { one = "{n} nota a mais omitida deste relatório", other = "{n} notas a mais omitidas deste relatório" } "{n} of {total}" = "{n} de {total}" "{n} people joined" = { one = "{n} pessoa entrou", other = "{n} pessoas entraram" } "{n} preferred components" = { one = "{n} componente preferido", other = "{n} componentes preferidos" } diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 3ab40757..fb090d36 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -267,6 +267,7 @@ name = "Русский" "Commits" = "Коммиты" "Company" = "Компания" "Compare changes" = "Сравнить изменения" +"Compatibility notes" = "Замечания о совместимости" "Completed" = "Завершено" "Component" = "Компонент" "Component name" = "Имя компонента" @@ -473,6 +474,7 @@ name = "Русский" "False" = "Ложь" "Feb" = "февр." "Fetching from the server…" = "Загрузка с сервера…" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft пока поддерживает не все возможности этих файлов. В замечаниях перечислено, что было пропущено или изменено в импортированной копии; исходный файл не меняется." "File" = "Файл" "File name" = "Имя файла" "File not found" = "Файл не найден" @@ -596,11 +598,14 @@ name = "Русский" "If {condition}" = "Если {condition}" "Ignore auto layout (absolute position)" = "Игнорировать автолейаут (абсолютная позиция)" "Image" = "Изображение" +"Images" = "Изображения" "Import" = "Импорт" "Import path" = "Путь импорта" "Import plugin from manifest" = "Импортировать плагин из манифеста" "Import plugin from manifest…" = "Импортировать плагин из манифеста…" +"Import report" = "Отчёт об импорте" "Import variables" = "Импортировать переменные" +"Imported document" = "Импортированный документ" "Imported {name}" = "Импортировано: {name}" "Imported {n} new variables, updated {updated}" = { one = "Импортирована {n} новая переменная, обновлено: {updated}", few = "Импортированы {n} новые переменные, обновлено: {updated}", many = "Импортировано {n} новых переменных, обновлено: {updated}", other = "Импортировано {n} новой переменной, обновлено: {updated}" } "Import…" = "Импорт…" @@ -802,6 +807,7 @@ name = "Русский" "No colour variables fit here." = "Нет подходящих цветовых переменных." "No colour variables yet. Create them in Local variables." = "Цветовых переменных пока нет. Создайте их в разделе «Локальные переменные»." "No comments yet" = "Комментариев пока нет" +"No compatibility warnings reported" = "Предупреждений о совместимости нет" "No components yet. Select layers and press {keys}." = "Компонентов пока нет. Выделите слои и нажмите {keys}." "No conflicts." = "Конфликтов нет." "No contributor data was built into this copy." = "В этой сборке нет данных об участниках." @@ -893,6 +899,7 @@ name = "Русский" "Open overlay" = "Открыть оверлей" "Open prototype preview" = "Открыть предпросмотр прототипа" "Open variables ({count})" = "Открыть переменные ({count})" +"Opened as a new document" = "Открыт как новый документ" "Opened “{name}” in a new tab" = "«{name}» открыт в новой вкладке" "Open|comments" = "Открытые" "Options" = "Параметры" @@ -917,6 +924,8 @@ name = "Русский" "Padding" = "Внутренние отступы" "Page" = "Страница" "Pages" = "Страницы" +"Pages added to the document" = "Страницы добавлены в документ" +"Placed on the current page" = "Размещено на текущей странице" "Pages: {pages}" = "Страницы: {pages}" "Paint bucket" = "Заливка области" "Pan the canvas" = "Перемещение по холсту" @@ -1486,26 +1495,90 @@ name = "Русский" "call any command its access allows" = "вызывать любые команды в рамках разрешений" "call only: {commands}" = "вызывать только: {commands}" "can edit the document" = "может редактировать документ" +"conditional prototype actions nested too deeply were skipped" = "условные действия прототипа со слишком глубокой вложенностью пропущены" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "условные действия прототипа с условиями, которые нельзя вычислить, пропущены" +"connector labels were skipped" = "подписи соединительных линий пропущены" +"connectors whose ends can't be placed are empty" = "соединительные линии, концы которых нельзя разместить, пусты" "crop marks" = "метки обреза" +"damaged vector geometry was skipped" = "повреждённая векторная геометрия пропущена" +"damaged vector networks import as outlines" = "повреждённые векторные сети импортируются как контуры" "development: {path}" = "разработка: {path}" +"document children other than pages were skipped" = "дочерние элементы документа, не являющиеся страницами, пропущены" "e.g. count + 1" = "например, count + 1" "e.g. count >= 3 and not done" = "например, count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "ломаные и изогнутые соединительные линии без сохранённого пути импортируются как прямые" +"entries without a node type were skipped" = "записи без типа узла пропущены" "figcraft:// links from a browser, chat or email open this app and join the session." = "Ссылки figcraft:// из браузера, чата или почты открывают это приложение и подключают к сессии." +"filled areas of a vector with their own fill use the layer's fill" = "залитые области вектора с собственной заливкой используют заливку слоя" "from {server}" = "с {server}" +"gamepad triggers without exactly one known controller button were skipped" = "триггеры геймпада без ровно одной известной кнопки контроллера пропущены" +"grid tracks of fixed or content size import as equal tracks" = "дорожки сетки фиксированного размера или по содержимому импортируются как равные дорожки" "hidden" = "скрыто" "iOS" = "iOS" +"instance swap properties whose default component isn't in the file were skipped" = "свойства замены экземпляра, компонент по умолчанию которых отсутствует в файле, пропущены" +"instances inside their own main component were imported as frames" = "экземпляры внутри собственного основного компонента импортированы как фреймы" +"instances of components that aren't in the file show no layers" = "экземпляры компонентов, которых нет в файле, не показывают слоёв" +"instances of components that aren't in the file were imported as frames" = "экземпляры компонентов, которых нет в файле, импортированы как фреймы" +"instances that would nest a component inside itself were imported as frames" = "экземпляры, которые вложили бы компонент в самого себя, импортированы как фреймы" +"instances without a main component were imported as empty frames" = "экземпляры без основного компонента импортированы как пустые фреймы" +"invalid or oversized vector networks import as outlines" = "недопустимые или слишком большие векторные сети импортируются как контуры" +"invalid or oversized vector path data was skipped" = "недопустимые или слишком большие данные векторных путей пропущены" "just now" = "только что" +"key triggers without a supported key were skipped" = "триггеры клавиш без поддерживаемой клавиши пропущены" +"key up triggers import as key presses" = "триггеры отпускания клавиши импортируются как нажатия клавиши" +"layers without a position were placed at their parent's origin" = "слои без положения размещены в начале координат родителя" +"layers without a type were skipped" = "слои без типа пропущены" "locked" = "заблокировано" "no bleed" = "без вылета" "nobody else here yet" = "здесь пока больше никого нет" "now" = "сейчас" "or" = "или" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "соединения обводки в точках сохранены; пунктирные обводки и обводки переменной ширины рисуются с соединением слоя" +"prototype actions of unknown types were skipped" = "действия прототипа неизвестных типов пропущены" +"prototype actions without a destination in the file were skipped" = "действия прототипа без цели в файле пропущены" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "выражения прототипа с переменными, которых нет в JSON переменных, пропущены" +"prototype expressions with unsupported functions were skipped" = "выражения прототипа с неподдерживаемыми функциями пропущены" +"prototype interactions without supported actions were skipped" = "взаимодействия прототипа без поддерживаемых действий пропущены" +"prototype navigations of unknown types were skipped" = "действия навигации прототипа неизвестных типов пропущены" +"prototype transitions of unknown types play instantly" = "переходы прототипа неизвестных типов выполняются мгновенно" +"prototype triggers of unknown types were skipped" = "триггеры прототипа неизвестных типов пропущены" "put a widget on the canvas" = "размещать виджет на холсте" "read and edit the document" = "читать и редактировать документ" "read the document" = "читать документ" "reads the document" = "читает документ" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "действия «Задать переменную» для переменных, которых нет в JSON переменных, пропущены" +"set-variable actions with values that can't be evaluated were skipped" = "действия «Задать переменную» со значениями, которые нельзя вычислить, пропущены" +"set-variable actions with values that don't fit the variable were skipped" = "действия «Задать переменную» со значениями, не подходящими переменной, пропущены" +"set-variable actions without a value were skipped" = "действия «Задать переменную» без значения пропущены" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "действия смены режима переменных для коллекций или режимов, которых нет в JSON переменных, пропущены" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "некоторые векторные точки ссылаются на настройки точек, которых нет в файле; они используют соединения слоя и обычные углы" +"stars and polygons without point counts or geometry use the default point count" = "звёзды и многоугольники без числа вершин или геометрии используют число вершин по умолчанию" +"text style overrides refer to missing table entries" = "переопределения текстовых стилей ссылаются на отсутствующие записи таблицы" +"the file has no pages" = "в файле нет страниц" "the plugin" = "плагин" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "в JSON переменных нет `variableCollections` (ожидался ответ с локальными переменными), он проигнорирован" "this session only" = "только эта сессия" +"unknown component property types were skipped" = "неизвестные типы свойств компонентов пропущены" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "псевдонимы переменных, которых нет в JSON переменных (или другого типа), пропущены" +"variable bindings to properties the layer doesn't have were skipped" = "привязки переменных к свойствам, которых нет у слоя, пропущены" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "привязки к переменным, которых нет в JSON переменных (переменные библиотеки), пропущены" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "привязки переменных пропущены: в JSON файла нет определений переменных (передайте JSON локальных переменных как `variables` или вложите его `meta` рядом с JSON файла)" +"variable bindings whose variable type doesn't fit the property were skipped" = "привязки переменных, тип которых не подходит свойству, пропущены" +"variable collections without modes were skipped" = "коллекции переменных без режимов пропущены" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "обращения к режимам переменных в выражениях прототипа, не состоящие из переменной и одного из её режимов, пропущены" +"variable modes of collections that aren't in the file were skipped" = "режимы переменных коллекций, которых нет в файле, пропущены" +"variable modes of collections that aren't in the variables JSON were skipped" = "режимы переменных коллекций, которых нет в JSON переменных, пропущены" +"variable values that don't match their type (or composed colours) were skipped" = "значения переменных, не соответствующие своему типу (или составные цвета), пропущены" +"variables of unknown types were skipped" = "переменные неизвестных типов пропущены" +"variables whose collection isn't in the variables JSON were skipped" = "переменные, коллекции которых нет в JSON переменных, пропущены" +"variables without readable values were skipped" = "переменные без читаемых значений пропущены" +"vector points with unknown stroke joins use the layer join" = "векторные точки с неизвестными соединениями обводки используют соединение слоя" +"vectors without geometry are empty" = "векторы без геометрии пусты" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "векторы без геометрии пусты (экспортируйте JSON файла с geometry=paths)" +"very long text was cut to 1,048,576 characters" = "очень длинный текст обрезан до 1 048 576 символов" +"very long vector paths were skipped" = "очень длинные векторные пути пропущены" +"video actions are not supported and were skipped" = "действия с видео не поддерживаются и пропущены" +"video triggers are not supported and were skipped" = "триггеры видео не поддерживаются и пропущены" "yesterday" = "вчера" "{instances} in {files}" = "{instances} в {files}" "{language}: draft translation, waiting for a native speaker's review." = "{language}: черновой перевод, ожидает проверки носителем языка." @@ -1550,6 +1623,7 @@ name = "Русский" "{n} matches" = { one = "{n} совпадение", few = "{n} совпадения", many = "{n} совпадений", other = "{n} совпадения" } "{n} minutes ago" = { one = "{n} минуту назад", few = "{n} минуты назад", many = "{n} минут назад", other = "{n} минуты назад" } "{n} months ago" = { one = "{n} месяц назад", few = "{n} месяца назад", many = "{n} месяцев назад", other = "{n} месяца назад" } +"{n} more notes omitted from this report" = { one = "Ещё {n} замечание не включено в этот отчёт", few = "Ещё {n} замечания не включены в этот отчёт", many = "Ещё {n} замечаний не включено в этот отчёт", other = "Ещё {n} замечания не включено в этот отчёт" } "{n} of {total}" = "{n} из {total}" "{n} people joined" = { one = "Подключился {n} участник", few = "Подключились {n} участника", many = "Подключились {n} участников", other = "Подключилось {n} участника" } "{n} preferred components" = { one = "{n} предпочтительный компонент", few = "{n} предпочтительных компонента", many = "{n} предпочтительных компонентов", other = "{n} предпочтительного компонента" } diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index f7b2999d..070b68d7 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -268,6 +268,7 @@ name = "Svenska" "Commits" = "Commits" "Company" = "Företag" "Compare changes" = "Jämför ändringar" +"Compatibility notes" = "Kompatibilitetsanteckningar" "Completed" = "Slutförd" "Component" = "Komponent" "Component name" = "Komponentnamn" @@ -474,6 +475,7 @@ name = "Svenska" "False" = "Falskt" "Feb" = "feb" "Fetching from the server…" = "Hämtar från servern…" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft stöder ännu inte alla funktioner i de här filerna. Anteckningarna visar vad som utelämnades eller ändrades i den importerade kopian; originalfilen är oförändrad." "File" = "Fil" "File name" = "Filnamn" "File not found" = "Filen hittades inte" @@ -597,11 +599,14 @@ name = "Svenska" "If {condition}" = "Om {condition}" "Ignore auto layout (absolute position)" = "Ignorera automatisk layout (absolut position)" "Image" = "Bild" +"Images" = "Bilder" "Import" = "Importera" "Import path" = "Importsökväg" "Import plugin from manifest" = "Importera plugin från manifest" "Import plugin from manifest…" = "Importera plugin från manifest…" +"Import report" = "Importrapport" "Import variables" = "Importera variabler" +"Imported document" = "Importerat dokument" "Imported {name}" = "Importerade {name}" "Imported {n} new variables, updated {updated}" = { one = "Importerade {n} ny variabel, uppdaterade {updated}", other = "Importerade {n} nya variabler, uppdaterade {updated}" } "Import…" = "Importera…" @@ -803,6 +808,7 @@ name = "Svenska" "No colour variables fit here." = "Inga färgvariabler passar här." "No colour variables yet. Create them in Local variables." = "Inga färgvariabler än. Skapa dem i Lokala variabler." "No comments yet" = "Inga kommentarer än" +"No compatibility warnings reported" = "Inga kompatibilitetsvarningar rapporterades" "No components yet. Select layers and press {keys}." = "Inga komponenter än. Markera lager och tryck på {keys}." "No conflicts." = "Inga konflikter." "No contributor data was built into this copy." = "Inga uppgifter om bidragsgivare finns inbyggda i den här kopian." @@ -894,6 +900,7 @@ name = "Svenska" "Open overlay" = "Öppna överlägg" "Open prototype preview" = "Öppna prototypförhandsgranskning" "Open variables ({count})" = "Öppna variabler ({count})" +"Opened as a new document" = "Öppnat som ett nytt dokument" "Opened “{name}” in a new tab" = "Öppnade ”{name}” i en ny flik" "Open|comments" = "Öppna" "Options" = "Alternativ" @@ -918,6 +925,8 @@ name = "Svenska" "Padding" = "Utfyllnad" "Page" = "Sida" "Pages" = "Sidor" +"Pages added to the document" = "Sidor tillagda i dokumentet" +"Placed on the current page" = "Placerat på den aktuella sidan" "Pages: {pages}" = "Sidor: {pages}" "Paint bucket" = "Färghink" "Pan the canvas" = "Panorera arbetsytan" @@ -1487,26 +1496,90 @@ name = "Svenska" "call any command its access allows" = "anropa alla kommandon som dess åtkomst tillåter" "call only: {commands}" = "anropa endast: {commands}" "can edit the document" = "kan redigera dokumentet" +"conditional prototype actions nested too deeply were skipped" = "villkorliga prototypåtgärder som var nästlade för djupt hoppades över" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "villkorliga prototypåtgärder med villkor som inte kan utvärderas hoppades över" +"connector labels were skipped" = "etiketter på kopplingslinjer hoppades över" +"connectors whose ends can't be placed are empty" = "kopplingslinjer vars ändar inte kan placeras är tomma" "crop marks" = "skärmärken" +"damaged vector geometry was skipped" = "skadad vektorgeometri hoppades över" +"damaged vector networks import as outlines" = "skadade vektornätverk importeras som former" "development: {path}" = "utveckling: {path}" +"document children other than pages were skipped" = "underordnade objekt i dokumentet som inte är sidor hoppades över" "e.g. count + 1" = "t.ex. count + 1" "e.g. count >= 3 and not done" = "t.ex. count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "vinklade och böjda kopplingslinjer utan sparad bana importeras som raka linjer" +"entries without a node type were skipped" = "poster utan nodtyp hoppades över" "figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft://-länkar från en webbläsare, chatt eller e-post öppnar den här appen och ansluter till sessionen." +"filled areas of a vector with their own fill use the layer's fill" = "fyllda områden i en vektor med egen fyllning använder lagrets fyllning" "from {server}" = "från {server}" +"gamepad triggers without exactly one known controller button were skipped" = "spelkontrollsutlösare utan exakt en känd kontrollknapp hoppades över" +"grid tracks of fixed or content size import as equal tracks" = "rutnätsspår med fast storlek eller innehållsstorlek importeras som lika stora spår" "hidden" = "dold" "iOS" = "iOS" +"instance swap properties whose default component isn't in the file were skipped" = "egenskaper för instansbyte vars standardkomponent inte finns i filen hoppades över" +"instances inside their own main component were imported as frames" = "instanser inuti sin egen huvudkomponent importerades som ramar" +"instances of components that aren't in the file show no layers" = "instanser av komponenter som inte finns i filen visar inga lager" +"instances of components that aren't in the file were imported as frames" = "instanser av komponenter som inte finns i filen importerades som ramar" +"instances that would nest a component inside itself were imported as frames" = "instanser som skulle nästla en komponent i sig själv importerades som ramar" +"instances without a main component were imported as empty frames" = "instanser utan huvudkomponent importerades som tomma ramar" +"invalid or oversized vector networks import as outlines" = "ogiltiga eller för stora vektornätverk importeras som former" +"invalid or oversized vector path data was skipped" = "ogiltiga eller för stora vektorbanedata hoppades över" "just now" = "nyss" +"key triggers without a supported key were skipped" = "tangentutlösare utan en tangent som stöds hoppades över" +"key up triggers import as key presses" = "utlösare för släppt tangent importeras som tangenttryckningar" +"layers without a position were placed at their parent's origin" = "lager utan position placerades i det överordnade objektets origo" +"layers without a type were skipped" = "lager utan typ hoppades över" "locked" = "låst" "no bleed" = "inget utfall" "nobody else here yet" = "ingen annan här än" "now" = "nu" "or" = "eller" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "punkternas hörntyper behölls; streckade konturer och konturer med variabel bredd ritas med lagrets hörntyp" +"prototype actions of unknown types were skipped" = "prototypåtgärder av okända typer hoppades över" +"prototype actions without a destination in the file were skipped" = "prototypåtgärder utan mål i filen hoppades över" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "prototyputtryck med variabler som inte finns i variabel-JSON:en hoppades över" +"prototype expressions with unsupported functions were skipped" = "prototyputtryck med funktioner som inte stöds hoppades över" +"prototype interactions without supported actions were skipped" = "prototypinteraktioner utan åtgärder som stöds hoppades över" +"prototype navigations of unknown types were skipped" = "prototypnavigeringar av okända typer hoppades över" +"prototype transitions of unknown types play instantly" = "prototypövergångar av okända typer spelas upp direkt" +"prototype triggers of unknown types were skipped" = "prototyputlösare av okända typer hoppades över" "put a widget on the canvas" = "placera en widget på arbetsytan" "read and edit the document" = "läsa och redigera dokumentet" "read the document" = "läsa dokumentet" "reads the document" = "läser dokumentet" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "åtgärder för att ange variabel på variabler som inte finns i variabel-JSON:en hoppades över" +"set-variable actions with values that can't be evaluated were skipped" = "åtgärder för att ange variabel med värden som inte kan utvärderas hoppades över" +"set-variable actions with values that don't fit the variable were skipped" = "åtgärder för att ange variabel med värden som inte passar variabeln hoppades över" +"set-variable actions without a value were skipped" = "åtgärder för att ange variabel utan värde hoppades över" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "åtgärder för att ange variabelläge på samlingar eller lägen som inte finns i variabel-JSON:en hoppades över" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "vissa vektorpunkter hänvisar till punktinställningar som saknas i filen; de använder lagrets hörntyper och vanliga hörn" +"stars and polygons without point counts or geometry use the default point count" = "stjärnor och polygoner utan antal punkter eller geometri använder standardantalet punkter" +"text style overrides refer to missing table entries" = "åsidosättningar av textstilar hänvisar till tabellposter som saknas" +"the file has no pages" = "filen har inga sidor" "the plugin" = "pluginet" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "variabel-JSON:en har inga `variableCollections` (svaret med lokala variabler väntades) och ignorerades" "this session only" = "endast den här sessionen" +"unknown component property types were skipped" = "okända typer av komponentegenskaper hoppades över" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "alias till variabler som inte finns i variabel-JSON:en (eller är av en annan typ) hoppades över" +"variable bindings to properties the layer doesn't have were skipped" = "variabelbindningar till egenskaper som lagret inte har hoppades över" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "bindningar till variabler som inte finns i variabel-JSON:en (biblioteksvariabler) hoppades över" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "variabelbindningar hoppades över: fil-JSON:en har inga variabeldefinitioner (skicka den lokala variabel-JSON:en som `variables`, eller bädda in dess `meta` bredvid fil-JSON:en)" +"variable bindings whose variable type doesn't fit the property were skipped" = "variabelbindningar vars variabeltyp inte passar egenskapen hoppades över" +"variable collections without modes were skipped" = "variabelsamlingar utan lägen hoppades över" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "uppslag av variabellägen i prototyputtryck som inte är en variabel och ett av dess lägen hoppades över" +"variable modes of collections that aren't in the file were skipped" = "variabellägen för samlingar som inte finns i filen hoppades över" +"variable modes of collections that aren't in the variables JSON were skipped" = "variabellägen för samlingar som inte finns i variabel-JSON:en hoppades över" +"variable values that don't match their type (or composed colours) were skipped" = "variabelvärden som inte matchar sin typ (eller sammansatta färger) hoppades över" +"variables of unknown types were skipped" = "variabler av okända typer hoppades över" +"variables whose collection isn't in the variables JSON were skipped" = "variabler vars samling inte finns i variabel-JSON:en hoppades över" +"variables without readable values were skipped" = "variabler utan läsbara värden hoppades över" +"vector points with unknown stroke joins use the layer join" = "vektorpunkter med okända hörntyper använder lagrets hörntyp" +"vectors without geometry are empty" = "vektorer utan geometri är tomma" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "vektorer utan geometri är tomma (exportera fil-JSON:en med geometry=paths)" +"very long text was cut to 1,048,576 characters" = "mycket lång text kortades till 1 048 576 tecken" +"very long vector paths were skipped" = "mycket långa vektorbanor hoppades över" +"video actions are not supported and were skipped" = "videoåtgärder stöds inte och hoppades över" +"video triggers are not supported and were skipped" = "videoutlösare stöds inte och hoppades över" "yesterday" = "i går" "{instances} in {files}" = "{instances} i {files}" "{language}: draft translation, waiting for a native speaker's review." = "{language}: utkast till översättning, väntar på granskning av en modersmålstalare." @@ -1551,6 +1624,7 @@ name = "Svenska" "{n} matches" = { one = "{n} träff", other = "{n} träffar" } "{n} minutes ago" = { one = "för {n} minut sedan", other = "för {n} minuter sedan" } "{n} months ago" = { one = "för {n} månad sedan", other = "för {n} månader sedan" } +"{n} more notes omitted from this report" = { one = "{n} anteckning till utelämnades i den här rapporten", other = "{n} anteckningar till utelämnades i den här rapporten" } "{n} of {total}" = "{n} av {total}" "{n} people joined" = { one = "{n} person anslöt", other = "{n} personer anslöt" } "{n} preferred components" = { one = "{n} föredragen komponent", other = "{n} föredragna komponenter" } diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index a2be6b99..9a40771e 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -270,6 +270,7 @@ name = "Türkçe" "Commits" = "Commit'ler" "Company" = "Şirket" "Compare changes" = "Değişiklikleri karşılaştır" +"Compatibility notes" = "Uyumluluk notları" "Completed" = "Tamamlandı" "Component" = "Bileşen" "Component name" = "Bileşen adı" @@ -476,6 +477,7 @@ name = "Türkçe" "False" = "Yanlış" "Feb" = "Şub" "Fetching from the server…" = "Sunucudan getiriliyor…" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft bu dosyaların tüm özelliklerini henüz desteklemiyor. Notlar, içe aktarılan kopyada nelerin dışarıda bırakıldığını veya değiştirildiğini listeler; özgün dosya değişmez." "File" = "Dosya" "File name" = "Dosya adı" "File not found" = "Dosya bulunamadı" @@ -599,11 +601,14 @@ name = "Türkçe" "If {condition}" = "Eğer {condition}" "Ignore auto layout (absolute position)" = "Otomatik yerleşimi yok say (mutlak konum)" "Image" = "Görüntü" +"Images" = "Görüntüler" "Import" = "İçe aktar" "Import path" = "İçe aktarma yolu" "Import plugin from manifest" = "Eklentiyi manifestten içe aktar" "Import plugin from manifest…" = "Eklentiyi manifestten içe aktar…" +"Import report" = "İçe aktarma raporu" "Import variables" = "Değişkenleri içe aktar" +"Imported document" = "İçe aktarılan belge" "Imported {name}" = "İçe aktarıldı: {name}" "Imported {n} new variables, updated {updated}" = { other = "{n} yeni değişken içe aktarıldı, {updated} güncellendi" } "Import…" = "İçe aktar…" @@ -805,6 +810,7 @@ name = "Türkçe" "No colour variables fit here." = "Buraya uyan renk değişkeni yok." "No colour variables yet. Create them in Local variables." = "Henüz renk değişkeni yok. Onları Yerel değişkenler'de oluşturun." "No comments yet" = "Henüz yorum yok" +"No compatibility warnings reported" = "Uyumluluk uyarısı bildirilmedi" "No components yet. Select layers and press {keys}." = "Henüz bileşen yok. Katmanları seçip {keys} tuşlarına basın." "No conflicts." = "Çakışma yok." "No contributor data was built into this copy." = "Bu kopyaya katkıda bulunan verisi eklenmemiş." @@ -896,6 +902,7 @@ name = "Türkçe" "Open overlay" = "Bindirmeyi aç" "Open prototype preview" = "Prototip önizlemesini aç" "Open variables ({count})" = "Açık değişkenler ({count})" +"Opened as a new document" = "Yeni belge olarak açıldı" "Opened “{name}” in a new tab" = "“{name}” yeni sekmede açıldı" "Open|comments" = "Açık" "Options" = "Seçenekler" @@ -920,6 +927,8 @@ name = "Türkçe" "Padding" = "İç boşluk" "Page" = "Sayfa" "Pages" = "Sayfalar" +"Pages added to the document" = "Sayfalar belgeye eklendi" +"Placed on the current page" = "Geçerli sayfaya yerleştirildi" "Pages: {pages}" = "Sayfalar: {pages}" "Paint bucket" = "Boya kovası" "Pan the canvas" = "Tuvali kaydır" @@ -1489,26 +1498,90 @@ name = "Türkçe" "call any command its access allows" = "erişiminin izin verdiği her komutu çağırmak" "call only: {commands}" = "yalnızca şunları çağırmak: {commands}" "can edit the document" = "belgeyi düzenleyebilir" +"conditional prototype actions nested too deeply were skipped" = "çok derin iç içe geçmiş koşullu prototip eylemleri atlandı" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "değerlendirilemeyen koşullara sahip koşullu prototip eylemleri atlandı" +"connector labels were skipped" = "bağlayıcı etiketleri atlandı" +"connectors whose ends can't be placed are empty" = "uçları yerleştirilemeyen bağlayıcılar boş" "crop marks" = "kesim işaretleri" +"damaged vector geometry was skipped" = "hasarlı vektör geometrisi atlandı" +"damaged vector networks import as outlines" = "hasarlı vektör ağları ana hat olarak içe aktarılır" "development: {path}" = "geliştirme: {path}" +"document children other than pages were skipped" = "belgenin sayfa olmayan alt öğeleri atlandı" "e.g. count + 1" = "ör. count + 1" "e.g. count >= 3 and not done" = "ör. count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "kaydedilmiş yolu olmayan dirsekli ve eğri bağlayıcılar düz çizgi olarak içe aktarılır" +"entries without a node type were skipped" = "düğüm türü olmayan girdiler atlandı" "figcraft:// links from a browser, chat or email open this app and join the session." = "Bir tarayıcıdan, sohbetten veya e-postadan gelen figcraft:// bağlantıları bu uygulamayı açar ve oturuma katılır." +"filled areas of a vector with their own fill use the layer's fill" = "kendi dolgusu olan vektör dolgu alanları katmanın dolgusunu kullanır" "from {server}" = "kaynak: {server}" +"gamepad triggers without exactly one known controller button were skipped" = "tam olarak bir bilinen kumanda düğmesi olmayan oyun kumandası tetikleyicileri atlandı" +"grid tracks of fixed or content size import as equal tracks" = "sabit veya içerik boyutlu ızgara izleri eşit izler olarak içe aktarılır" "hidden" = "gizli" "iOS" = "iOS" +"instance swap properties whose default component isn't in the file were skipped" = "varsayılan bileşeni dosyada olmayan örnek değiştirme özellikleri atlandı" +"instances inside their own main component were imported as frames" = "kendi ana bileşeninin içindeki örnekler çerçeve olarak içe aktarıldı" +"instances of components that aren't in the file show no layers" = "dosyada olmayan bileşenlerin örnekleri katman göstermez" +"instances of components that aren't in the file were imported as frames" = "dosyada olmayan bileşenlerin örnekleri çerçeve olarak içe aktarıldı" +"instances that would nest a component inside itself were imported as frames" = "bir bileşeni kendi içine yerleştirecek örnekler çerçeve olarak içe aktarıldı" +"instances without a main component were imported as empty frames" = "ana bileşeni olmayan örnekler boş çerçeve olarak içe aktarıldı" +"invalid or oversized vector networks import as outlines" = "geçersiz veya çok büyük vektör ağları ana hat olarak içe aktarılır" +"invalid or oversized vector path data was skipped" = "geçersiz veya çok büyük vektör yolu verileri atlandı" "just now" = "az önce" +"key triggers without a supported key were skipped" = "desteklenen bir tuşu olmayan tuş tetikleyicileri atlandı" +"key up triggers import as key presses" = "tuş bırakma tetikleyicileri tuşa basma olarak içe aktarılır" +"layers without a position were placed at their parent's origin" = "konumu olmayan katmanlar üst öğelerinin başlangıç noktasına yerleştirildi" +"layers without a type were skipped" = "türü olmayan katmanlar atlandı" "locked" = "kilitli" "no bleed" = "taşma payı yok" "nobody else here yet" = "burada henüz başka kimse yok" "now" = "şimdi" "or" = "veya" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "nokta başına kontur birleşimleri korundu; kesikli veya değişken genişlikli konturlar katmanın birleşimiyle çizilir" +"prototype actions of unknown types were skipped" = "bilinmeyen türdeki prototip eylemleri atlandı" +"prototype actions without a destination in the file were skipped" = "dosyada hedefi olmayan prototip eylemleri atlandı" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "değişken JSON'unda olmayan değişkenler üzerindeki prototip ifadeleri atlandı" +"prototype expressions with unsupported functions were skipped" = "desteklenmeyen işlevler içeren prototip ifadeleri atlandı" +"prototype interactions without supported actions were skipped" = "desteklenen eylemi olmayan prototip etkileşimleri atlandı" +"prototype navigations of unknown types were skipped" = "bilinmeyen türdeki prototip gezinmeleri atlandı" +"prototype transitions of unknown types play instantly" = "bilinmeyen türdeki prototip geçişleri anında oynatılır" +"prototype triggers of unknown types were skipped" = "bilinmeyen türdeki prototip tetikleyicileri atlandı" "put a widget on the canvas" = "tuvale bir widget koymak" "read and edit the document" = "belgeyi okumak ve düzenlemek" "read the document" = "belgeyi okumak" "reads the document" = "belgeyi okur" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "değişken JSON'unda olmayan değişkenler üzerindeki değişken ayarlama eylemleri atlandı" +"set-variable actions with values that can't be evaluated were skipped" = "değerlendirilemeyen değerlere sahip değişken ayarlama eylemleri atlandı" +"set-variable actions with values that don't fit the variable were skipped" = "değişkene uymayan değerlere sahip değişken ayarlama eylemleri atlandı" +"set-variable actions without a value were skipped" = "değeri olmayan değişken ayarlama eylemleri atlandı" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "değişken JSON'unda olmayan koleksiyonlar veya modlar üzerindeki değişken modu ayarlama eylemleri atlandı" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "bazı vektör noktaları dosyada eksik olan nokta ayarlarına başvuruyor; bu noktalar katmanın birleşimlerini ve düz köşeleri kullanır" +"stars and polygons without point counts or geometry use the default point count" = "nokta sayısı veya geometrisi olmayan yıldızlar ve çokgenler varsayılan nokta sayısını kullanır" +"text style overrides refer to missing table entries" = "metin stili geçersiz kılmaları eksik tablo girdilerine başvuruyor" +"the file has no pages" = "dosyada sayfa yok" "the plugin" = "eklenti" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "değişken JSON'unda `variableCollections` yok (yerel değişkenler yanıtı bekleniyordu) ve yok sayıldı" "this session only" = "yalnızca bu oturum" +"unknown component property types were skipped" = "bilinmeyen bileşen özelliği türleri atlandı" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "değişken JSON'unda olmayan (veya başka türdeki) değişkenlere yönelik takma adlar atlandı" +"variable bindings to properties the layer doesn't have were skipped" = "katmanda olmayan özelliklere yönelik değişken bağlamaları atlandı" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "değişken JSON'unda olmayan değişkenlere (kitaplık değişkenleri) yönelik bağlamalar atlandı" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "değişken bağlamaları atlandı: dosya JSON'unda değişken tanımı yok (yerel değişken JSON'unu `variables` olarak verin veya `meta` alanını dosya JSON'unun yanına gömün)" +"variable bindings whose variable type doesn't fit the property were skipped" = "değişken türü özelliğe uymayan değişken bağlamaları atlandı" +"variable collections without modes were skipped" = "modu olmayan değişken koleksiyonları atlandı" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "prototip ifadelerinde bir değişken ve onun modlarından biri olmayan değişken modu aramaları atlandı" +"variable modes of collections that aren't in the file were skipped" = "dosyada olmayan koleksiyonların değişken modları atlandı" +"variable modes of collections that aren't in the variables JSON were skipped" = "değişken JSON'unda olmayan koleksiyonların değişken modları atlandı" +"variable values that don't match their type (or composed colours) were skipped" = "türüyle eşleşmeyen değişken değerleri (veya birleşik renkler) atlandı" +"variables of unknown types were skipped" = "bilinmeyen türdeki değişkenler atlandı" +"variables whose collection isn't in the variables JSON were skipped" = "koleksiyonu değişken JSON'unda olmayan değişkenler atlandı" +"variables without readable values were skipped" = "okunabilir değeri olmayan değişkenler atlandı" +"vector points with unknown stroke joins use the layer join" = "bilinmeyen kontur birleşimleri olan vektör noktaları katmanın birleşimini kullanır" +"vectors without geometry are empty" = "geometrisi olmayan vektörler boş" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "geometrisi olmayan vektörler boş (dosya JSON'unu geometry=paths ile dışa aktarın)" +"very long text was cut to 1,048,576 characters" = "çok uzun metin 1.048.576 karaktere kısaltıldı" +"very long vector paths were skipped" = "çok uzun vektör yolları atlandı" +"video actions are not supported and were skipped" = "video eylemleri desteklenmiyor ve atlandı" +"video triggers are not supported and were skipped" = "video tetikleyicileri desteklenmiyor ve atlandı" "yesterday" = "dün" "{instances} in {files}" = "{instances}, {files} içinde" "{language}: draft translation, waiting for a native speaker's review." = "{language}: taslak çeviri, ana dili konuşan birinin incelemesini bekliyor." @@ -1553,6 +1626,7 @@ name = "Türkçe" "{n} matches" = { other = "{n} eşleşme" } "{n} minutes ago" = { other = "{n} dakika önce" } "{n} months ago" = { other = "{n} ay önce" } +"{n} more notes omitted from this report" = { other = "{n} not daha bu rapora alınmadı" } "{n} of {total}" = "{n} / {total}" "{n} people joined" = { other = "{n} kişi katıldı" } "{n} preferred components" = { other = "{n} tercih edilen bileşen" } diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index af4cf653..4e740329 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -262,6 +262,7 @@ status = "draft — needs native review" "Commits" = "提交" "Company" = "公司" "Compare changes" = "比较更改" +"Compatibility notes" = "兼容性说明" "Completed" = "已完成" "Component" = "组件" "Component name" = "组件名称" @@ -461,6 +462,7 @@ status = "draft — needs native review" "False" = "假" "Fetching from the server…" = "正在从服务器获取…" "FigCraft couldn't open its window: {error}" = "FigCraft 无法打开窗口:{error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft 尚不支持这些文件的全部功能。说明中列出了导入副本中省略或更改的内容;原始文件保持不变。" "File" = "文件" "File name" = "文件名" "File published" = "文件已发布" @@ -577,11 +579,14 @@ status = "draft — needs native review" "If {condition}" = "如果 {condition}" "Ignore auto layout (absolute position)" = "忽略自动布局(绝对定位)" "Image" = "图片" +"Images" = "图片" "Import" = "导入" "Import path" = "导入路径" "Import plugin from manifest" = "从清单导入插件" "Import plugin from manifest…" = "从清单导入插件…" +"Import report" = "导入报告" "Import variables" = "导入变量" +"Imported document" = "导入的文档" "Imported {name}" = "已导入 {name}" "Imported {n} new variables, updated {updated}" = { other = "已导入 {n} 个新变量,更新 {updated} 个" } "Import…" = "导入…" @@ -771,6 +776,7 @@ status = "draft — needs native review" "No colour variables fit here." = "没有适用于此处的颜色变量。" "No colour variables yet. Create them in Local variables." = "还没有颜色变量。请在“本地变量”中创建。" "No comments yet" = "还没有评论" +"No compatibility warnings reported" = "未报告兼容性警告" "No components yet. Select layers and press {keys}." = "还没有组件。选择图层并按 {keys}。" "No conflicts." = "没有冲突。" "No contributor data was built into this copy." = "此副本未内置贡献者数据。" @@ -857,6 +863,7 @@ status = "draft — needs native review" "Open overlay" = "打开浮层" "Open prototype preview" = "打开原型预览" "Open variables ({count})" = "打开变量({count})" +"Opened as a new document" = "已作为新文档打开" "Opened “{name}” in a new tab" = "已在新标签页中打开“{name}”" "Open|comments" = "未解决" "Options" = "选项" @@ -880,6 +887,8 @@ status = "draft — needs native review" "Padding" = "内边距" "Page" = "页面" "Pages" = "页面" +"Pages added to the document" = "页面已添加到文档" +"Placed on the current page" = "已放置在当前页面" "Pages: {pages}" = "页面:{pages}" "Paint bucket" = "油漆桶" "Pan the canvas" = "平移画布" @@ -1427,25 +1436,89 @@ status = "draft — needs native review" "call any command its access allows" = "调用其访问权限允许的任何命令" "call only: {commands}" = "仅调用:{commands}" "can edit the document" = "可编辑文档" +"conditional prototype actions nested too deeply were skipped" = "已跳过嵌套过深的条件原型操作" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "已跳过条件无法求值的条件原型操作" +"connector labels were skipped" = "已跳过连接线标签" +"connectors whose ends can't be placed are empty" = "无法放置端点的连接线为空" "crop marks" = "裁切标记" +"damaged vector geometry was skipped" = "已跳过损坏的矢量几何" +"damaged vector networks import as outlines" = "损坏的矢量网络导入为轮廓" "development: {path}" = "开发:{path}" +"document children other than pages were skipped" = "已跳过非页面的文档子项" "e.g. count + 1" = "例如 count + 1" "e.g. count >= 3 and not done" = "例如 count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "没有已保存路径的折线和曲线连接线导入为直线" +"entries without a node type were skipped" = "已跳过没有节点类型的条目" "figcraft:// links from a browser, chat or email open this app and join the session." = "来自浏览器、聊天或邮件的 figcraft:// 链接会打开此应用并加入会话。" +"filled areas of a vector with their own fill use the layer's fill" = "具有自身填充的矢量填充区域使用图层的填充" "from {server}" = "来自 {server}" +"gamepad triggers without exactly one known controller button were skipped" = "已跳过没有恰好一个已知手柄按钮的游戏手柄触发器" +"grid tracks of fixed or content size import as equal tracks" = "固定尺寸或按内容尺寸的网格轨道导入为等分轨道" "hidden" = "已隐藏" "iOS" = "iOS" +"instance swap properties whose default component isn't in the file were skipped" = "已跳过默认组件不在文件中的实例替换属性" +"instances inside their own main component were imported as frames" = "位于自身主组件内的实例已导入为画框" +"instances of components that aren't in the file show no layers" = "文件中没有的组件的实例不显示图层" +"instances of components that aren't in the file were imported as frames" = "文件中没有的组件的实例已导入为画框" +"instances that would nest a component inside itself were imported as frames" = "会将组件嵌套到自身内部的实例已导入为画框" +"instances without a main component were imported as empty frames" = "没有主组件的实例已导入为空画框" +"invalid or oversized vector networks import as outlines" = "无效或过大的矢量网络导入为轮廓" +"invalid or oversized vector path data was skipped" = "已跳过无效或过大的矢量路径数据" +"key triggers without a supported key were skipped" = "已跳过没有受支持按键的按键触发器" +"key up triggers import as key presses" = "松开按键触发器导入为按下按键" +"layers without a position were placed at their parent's origin" = "没有位置的图层已放在其父级的原点" +"layers without a type were skipped" = "已跳过没有类型的图层" "locked" = "已锁定" "no bleed" = "无出血" "nobody else here yet" = "这里还没有其他人" "now" = "刚刚" "or" = "或" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "已保留各点的描边转角;虚线描边或可变宽度描边以图层的转角绘制" +"prototype actions of unknown types were skipped" = "已跳过未知类型的原型操作" +"prototype actions without a destination in the file were skipped" = "已跳过在文件中没有目标的原型操作" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "已跳过使用变量 JSON 中没有的变量的原型表达式" +"prototype expressions with unsupported functions were skipped" = "已跳过使用不受支持函数的原型表达式" +"prototype interactions without supported actions were skipped" = "已跳过没有受支持操作的原型交互" +"prototype navigations of unknown types were skipped" = "已跳过未知类型的原型导航" +"prototype transitions of unknown types play instantly" = "未知类型的原型过渡即时播放" +"prototype triggers of unknown types were skipped" = "已跳过未知类型的原型触发器" "put a widget on the canvas" = "在画布上放置小组件" "read and edit the document" = "读取和编辑文档" "read the document" = "读取文档" "reads the document" = "读取文档" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "已跳过针对变量 JSON 中没有的变量的设置变量操作" +"set-variable actions with values that can't be evaluated were skipped" = "已跳过值无法求值的设置变量操作" +"set-variable actions with values that don't fit the variable were skipped" = "已跳过值与变量不符的设置变量操作" +"set-variable actions without a value were skipped" = "已跳过没有值的设置变量操作" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "已跳过针对变量 JSON 中没有的集合或模式的设置变量模式操作" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "部分矢量点引用了文件中缺失的点设置;这些点使用图层的转角和普通角" +"stars and polygons without point counts or geometry use the default point count" = "没有点数或几何的星形和多边形使用默认点数" +"text style overrides refer to missing table entries" = "文本样式覆盖引用了缺失的表条目" +"the file has no pages" = "文件没有页面" "the plugin" = "插件" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "变量 JSON 中没有 `variableCollections`(应为本地变量响应),已忽略" "this session only" = "仅限本次会话" +"unknown component property types were skipped" = "已跳过未知的组件属性类型" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "已跳过指向变量 JSON 中没有的(或其他类型的)变量的别名" +"variable bindings to properties the layer doesn't have were skipped" = "已跳过绑定到图层没有的属性的变量绑定" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "已跳过绑定到变量 JSON 中没有的变量(库变量)的绑定" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "已跳过变量绑定:文件 JSON 中没有变量定义(将本地变量 JSON 作为 `variables` 传入,或将其 `meta` 嵌入文件 JSON 旁)" +"variable bindings whose variable type doesn't fit the property were skipped" = "已跳过变量类型与属性不符的变量绑定" +"variable collections without modes were skipped" = "已跳过没有模式的变量集合" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "已跳过原型表达式中不是变量及其某个模式的变量模式查找" +"variable modes of collections that aren't in the file were skipped" = "已跳过文件中没有的集合的变量模式" +"variable modes of collections that aren't in the variables JSON were skipped" = "已跳过变量 JSON 中没有的集合的变量模式" +"variable values that don't match their type (or composed colours) were skipped" = "已跳过与其类型不符的变量值(或组合颜色)" +"variables of unknown types were skipped" = "已跳过未知类型的变量" +"variables whose collection isn't in the variables JSON were skipped" = "已跳过其集合不在变量 JSON 中的变量" +"variables without readable values were skipped" = "已跳过没有可读值的变量" +"vector points with unknown stroke joins use the layer join" = "转角未知的矢量点使用图层的转角" +"vectors without geometry are empty" = "没有几何的矢量为空" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "没有几何的矢量为空(使用 geometry=paths 导出文件 JSON)" +"very long text was cut to 1,048,576 characters" = "过长的文本已截断为 1,048,576 个字符" +"very long vector paths were skipped" = "已跳过过长的矢量路径" +"video actions are not supported and were skipped" = "不支持视频操作,已跳过" +"video triggers are not supported and were skipped" = "不支持视频触发器,已跳过" "{instances} in {files}" = "{files} 中的 {instances}" "{language}: draft translation, waiting for a native speaker's review." = "{language}:翻译草稿,等待母语者审校。" "{length} bleed" = "出血 {length}" @@ -1488,6 +1561,7 @@ status = "draft — needs native review" "{n} matches" = { other = "{n} 个匹配项" } "{n} minutes ago" = { other = "{n} 分钟前" } "{n} months ago" = { other = "{n} 个月前" } +"{n} more notes omitted from this report" = { other = "另有 {n} 条说明未列入此报告" } "{n} of {total}" = "第 {n} 个,共 {total} 个" "{n} people joined" = { other = "{n} 人已加入" } "{n} preferred components" = { other = "{n} 个首选组件" } From 4a572572dbb79bf66cfae89cdc96463cd503cafb Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:25:56 +0700 Subject: [PATCH 091/118] #635: no negative zero from the flipped tint --- crates/format/src/rest/props.rs | 4 ++-- crates/format/src/rest/tests.rs | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index 0ee2d687..5fe20c71 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -208,8 +208,8 @@ fn image(cx: &mut Ctx, p: &Value) -> ImagePaint { saturation: filter("saturation"), temperature: filter("temperature"), // The files' tint runs from green (negative) to magenta (positive), in REST file JSON and - // .fig alike; a positive native tint adds green. - tint: -filter("tint"), + // .fig alike; a positive native tint adds green. (`0 - x`: no negative zero.) + tint: 0.0 - filter("tint"), highlights: filter("highlights"), shadows: filter("shadows"), }, diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index 97e5d828..281d0896 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -194,7 +194,7 @@ fn image_filter_tint_turns_magenta_positive_into_native_green_positive() { "fills": [{"type": "IMAGE", "imageRef": "tint", "filters": {"tint": tint}}] }) }; - let (doc, warnings) = one(json!([rect("Magenta", 0.75), rect("Green", -0.5)])); + let (doc, warnings) = one(json!([rect("Magenta", 0.75), rect("Green", -0.5), rect("Neutral", 0.0)])); let image = |name: &str| { let PaintKind::Image(image) = &named(&doc, name).fills[0].kind else { panic!("{name} is not an image fill") }; image @@ -202,6 +202,7 @@ fn image_filter_tint_turns_magenta_positive_into_native_green_positive() { // The same sign as a `.fig` import (`fig::tests::image_filters`). assert!(close(f64::from(image("Magenta").filters.tint), -0.75)); assert!(close(f64::from(image("Green").filters.tint), 0.5)); + assert!(image("Neutral").filters.tint.is_sign_positive(), "no negative zero"); assert!(!warnings.iter().any(|w| w.contains("approximate renderer")), "{warnings:?}"); } From 02c4d7d8259c44ba536a347502e023cc7cae7a8c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:27:08 +0700 Subject: [PATCH 092/118] #620: test the summary and report of a file opened from data --- crates/ui-egui/src/import_report_tests.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/ui-egui/src/import_report_tests.rs b/crates/ui-egui/src/import_report_tests.rs index bc5fc8bb..8742ccd3 100644 --- a/crates/ui-egui/src/import_report_tests.rs +++ b/crates/ui-egui/src/import_report_tests.rs @@ -185,3 +185,19 @@ fn a_dropped_file_shows_its_report_once_it_opens() { let report = u.app.ui.import_report.as_ref().expect("the dropped file's report"); assert_eq!(report.uid, u.app.session.ds().unwrap().uid); } + +#[test] +fn a_file_opened_from_base64_data_has_a_summary_too() { + use base64::Engine as _; + let file = json!({"document": {"id": "0:0", "name": "From data", "type": "DOCUMENT", "children": [ + {"id": "0:1", "name": "Page 1", "type": "CANVAS", "children": [{"id": "1:1", "name": "No type"}]}]}}); + let data = base64::engine::general_purpose::STANDARD.encode(file.to_string()); + let mut app = FigApp::new(Session::new(), Box::new(NoServices)); + let opened = app.run_user("file.open", json!({"data": data, "name": "Dropped.json"})).unwrap(); + assert_eq!(opened["summary"]["pages"], 1); + let report = app.ui.import_report.as_ref().expect("a report for the REST import"); + assert_eq!(report.pages, Some(1)); + assert!(report.notes.iter().any(|n| n == "layers without a type were skipped"), "{:?}", report.notes); + // Shown as a sentence, worked out once when the report opens. + assert!(report.shown.iter().any(|n| n == "Layers without a type were skipped"), "{:?}", report.shown); +} From 946006f1007c8a17118e50dd496017920a30ad4f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:34:56 +0700 Subject: [PATCH 093/118] #647: leave progress docs to the maintainer's docs PR --- docs/file-format-parity.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 63f281f6..d8e51711 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (paste from Figma (desktop); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -36,7 +36,7 @@ and prototype interactions are still unchecked there. | BMP | — | yes | | — | | MP4, MOV, WebM (video fills, paid plans) | yes | **no** | shortcut: decoding H.264/HEVC/VP9 needs non-Rust libraries (FFmpeg or each platform's media framework); presenter playback would reuse the animated GIF clock | 4–8 | | Images over 4096 px | downscaled to 4096 | kept | | — | -| Pasted content from other apps | images, SVG, text, Figma clipboard | images, SVG, text, **layers copied in Figma** (desktop; best effort) (newest clipboard content wins) | layers copied in Figma arrive as a `.fig` canvas in the clipboard's HTML; `edit.pasteFromFigma` reads it with the `.fig` import and pastes it as Paste does (into the selected frame, in place or in the view's centre), with `warnings`. Image fills come without their pixels (the clipboard doesn't carry them). Not in the browser (its paste event carries plain text only). Tested on copies written by our own `.fig` export, not yet on real Figma copies | with `.fig` | +| Pasted content from other apps | images, SVG, text, Figma clipboard | images, SVG, text (newest clipboard content wins) | no Figma clipboard (it carries Figma's own binary) | with `.fig` | ## Export @@ -162,7 +162,6 @@ each. | Date | Change | Summary | |---|---|---| -| 2026-10-14 | minor | Paste from Figma (desktop): layers copied in Figma paste as layers. Paste reads the clipboard's HTML; its `.fig` canvas goes through the `.fig` import and lands as Paste places our own layers (`edit.pasteFromFigma`, with `warnings`). Tested on copies written by our own `.fig` export only. Numbers unchanged | | 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. | | 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Opened `.fig` and file JSON documents link nested instances of variants (and any nested instance under a name of its own) to their main's layers; they stayed unlinked, so a second export lost what they changed. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | | 2026-10-12 | minor | `.fig` export made to open elsewhere (gap 1): the document and its pages say they are visible and opaque (left out, nothing was drawn), the schema in the file uses the format's own names, kinds, field ids and enum values, rectangles are rounded rectangles with four radii, frames and shapes carry their fill outline and plain strokes their stroke outline, text carries its layout settings, font styles use their standard names, line heights that are a share of the font size are raw multipliers, masks keep their type (read back too); the import reads per-side stroke weights whose zero sides are left out. Written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers unchanged | From 3353d2d5d3c399ec4c586f35ea5ccb0edef8a10a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:34:57 +0700 Subject: [PATCH 094/118] #647: edit.pasteDesignLayers, named neutrally; design data capped at 256 MB before decoding, data accepts wrapped or unpadded base64 --- crates/engine/src/cmd/edit.rs | 51 ++++++++++-------- ...a_paste_tests.rs => design_paste_tests.rs} | 52 ++++++++++++------- crates/engine/src/lib.rs | 4 +- ...a_paste_tests.rs => design_paste_tests.rs} | 0 4 files changed, 65 insertions(+), 42 deletions(-) rename crates/engine/src/{figma_paste_tests.rs => design_paste_tests.rs} (53%) rename crates/ui-egui/src/{figma_paste_tests.rs => design_paste_tests.rs} (100%) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 27d9652e..21f09569 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -26,11 +26,11 @@ pub fn specs() -> Vec { spec!("edit.cut", "Cut", "{ids?}", true, cut), spec!("edit.paste", "Paste", "{parent?, x?, y? (page coordinates of the pasted top-left)}", true, paste), spec!( - "edit.pasteFromFigma", - "Paste from Figma", - "{html (the clipboard's HTML) | data (base64 of the copied buffer), parent?, x?, y?} layers copied in Figma, read like a .fig file (best effort, with `warnings`), pasted as edit.paste pastes", + "edit.pasteDesignLayers", + "Paste design layers", + "{html (the clipboard's HTML) | data (base64 of the clipboard's design data, at most 256 MB), parent?, x?, y?} layers copied from another design app (a .fig canvas in the clipboard's HTML), read like a .fig file (best effort, with `warnings`), pasted as edit.paste pastes", true, - paste_from_figma + paste_design_layers ), spec!("edit.pasteOver", "Paste over selection", "{}", true, paste_over), spec!("edit.pasteReplace", "Paste to replace", "{ids?}", true, paste_replace), @@ -544,35 +544,42 @@ fn copied(doc: &Document, ids: &[NodeId]) -> Copied { (nodes, images, bounds) } -const MAX_FIGMA_CLIP: usize = 512 * 1024 * 1024; +/// The markers around the copied layers in the clipboard's HTML, spelt as that format spells them. +pub const CLIP_OPEN: &str = "(figma)"; +pub const CLIP_CLOSE: &str = "(/figma)"; +/// Largest design data read from the clipboard: the `.fig` import's own canvas cap (256 MB). Its +/// base64 is checked first, so neither the text nor the decoded bytes can pass it. +const MAX_CLIP_DATA: usize = 256 * 1024 * 1024; +const MAX_CLIP_BASE64: usize = MAX_CLIP_DATA.div_ceil(3) * 4; -/// Figma puts the copied layers in the HTML as `(figma)(/figma)`. This gives those bytes. -pub fn figma_clipboard_buffer(html: &str) -> Option> { +/// Base64 `text` (whitespace ignored, padding optional) when it fits [`MAX_CLIP_DATA`]. +fn clip_base64(text: &str) -> Option> { use base64::Engine as _; - const OPEN: &str = "(figma)"; - let start = html.find(OPEN)? + OPEN.len(); - let len = html.get(start..)?.find("(/figma)")?; - let text = html.get(start..start + len)?; - if text.len() > MAX_FIGMA_CLIP { + if text.bytes().filter(|b| !b.is_ascii_whitespace()).count() > MAX_CLIP_BASE64 { return None; } let b64: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect(); - let bytes = - base64::engine::general_purpose::STANDARD.decode(&b64).or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&b64)).ok()?; + base64::engine::general_purpose::STANDARD.decode(&b64).or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&b64)).ok() +} + +/// Layers copied from another design app travel in the clipboard's HTML as a `.fig` canvas in +/// base64 between [`CLIP_OPEN`] and [`CLIP_CLOSE`]. This gives those bytes, or `None` when the HTML +/// holds no readable design data (it may just mention the marker). +pub fn design_clipboard_buffer(html: &str) -> Option> { + let start = html.find(CLIP_OPEN)? + CLIP_OPEN.len(); + let len = html.get(start..)?.find(CLIP_CLOSE)?; + let bytes = clip_base64(html.get(start..start + len)?)?; figcraft_format::fig::is_fig(&bytes).then_some(bytes) } -fn paste_from_figma(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "edit.pasteFromFigma"; +fn paste_design_layers(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "edit.pasteDesignLayers"; let bytes = match (string(p, "html"), string(p, "data")) { - (Some(html), _) => figma_clipboard_buffer(&html).ok_or_else(|| bad(CMD, "the HTML holds no layers copied in Figma"))?, - (None, Some(d)) => { - use base64::Engine as _; - base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| bad(CMD, e.to_string()))? - } + (Some(html), _) => design_clipboard_buffer(&html).ok_or_else(|| bad(CMD, "the HTML holds no copied design layers"))?, + (None, Some(d)) => clip_base64(&d).ok_or_else(|| bad(CMD, "`data` is not base64 of at most 256 MB"))?, (None, None) => return Err(bad(CMD, "pass `html` or `data`")), }; - let other = super::file::load_fig(&bytes, "Pasted from Figma").ok_or_else(|| bad(CMD, "the data is not layers copied in Figma"))??; + let other = super::file::load_fig(&bytes, "Pasted layers").ok_or_else(|| bad(CMD, "the data is not copied design layers"))??; let doc = std::sync::Arc::new(other.doc); let tops: Vec = doc.pages.iter().filter_map(|pg| doc.node(*pg)).flat_map(|pg| pg.children.iter().copied()).collect(); if tops.is_empty() { diff --git a/crates/engine/src/figma_paste_tests.rs b/crates/engine/src/design_paste_tests.rs similarity index 53% rename from crates/engine/src/figma_paste_tests.rs rename to crates/engine/src/design_paste_tests.rs index 7468f3ac..89de80ef 100644 --- a/crates/engine/src/figma_paste_tests.rs +++ b/crates/engine/src/design_paste_tests.rs @@ -1,8 +1,11 @@ +//! Paste of layers copied from another design app: the clipboard's design data (a `.fig` canvas in +//! its HTML) read through the `.fig` import and pasted as Paste pastes our own layers. + use base64::Engine as _; use serde_json::json; use super::*; -use crate::cmd::edit::figma_clipboard_buffer; +use crate::cmd::edit::{CLIP_CLOSE, CLIP_OPEN, design_clipboard_buffer}; const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD; @@ -20,12 +23,9 @@ fn copied_file() -> Vec { } fn clipboard_html(buffer: &[u8]) -> String { - let meta = B64.encode(br#"{"dataType":"scene"}"#); let mut wrapped = B64.encode(buffer); wrapped.insert(8, '\n'); - format!( - r#""# - ) + format!(r#""#) } fn names(s: &Session, ids: &[NodeId]) -> Vec { @@ -36,18 +36,31 @@ fn names(s: &Session, ids: &[NodeId]) -> Vec { #[test] fn buffer_is_found_in_the_html() { let file = copied_file(); - assert_eq!(figma_clipboard_buffer(&clipboard_html(&file)), Some(file)); - assert_eq!(figma_clipboard_buffer("

plain

"), None); - assert_eq!(figma_clipboard_buffer("(figma)not base64!(/figma)"), None); - assert_eq!(figma_clipboard_buffer(&format!("(figma){}(/figma)", B64.encode(b"hello"))), None); - assert_eq!(figma_clipboard_buffer("(figma)ZmlnLQ"), None); + assert_eq!(design_clipboard_buffer(&clipboard_html(&file)), Some(file)); + assert_eq!(design_clipboard_buffer("

plain

"), None); + assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}not base64!{CLIP_CLOSE}")), None); + assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}{}{CLIP_CLOSE}", B64.encode(b"hello"))), None); + assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}ZmlnLQ")), None); + // Web text that only mentions the markers holds no design data. + assert_eq!(design_clipboard_buffer(&format!("

see {CLIP_OPEN} and {CLIP_CLOSE} in the docs

")), None); +} + +#[test] +fn oversized_design_data_is_refused_before_decoding() { + // Just past the cap (256 MB of data, as base64), made of valid base64 characters. + let big = "A".repeat((256 * 1024 * 1024usize).div_ceil(3) * 4 + 4); + assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}{big}{CLIP_CLOSE}")), None); + let mut s = Session::new(); + let e = s.execute("edit.pasteDesignLayers", &json!({ "data": big })).unwrap_err(); + assert!(e.to_string().contains("256 MB"), "{e}"); + assert!(s.clipboard.nodes.is_empty()); } #[test] fn layers_paste_into_the_page_and_undo() { let mut s = Session::new(); let page = s.page().unwrap(); - let r = s.execute("edit.pasteFromFigma", &json!({"html": clipboard_html(&copied_file())})).unwrap(); + let r = s.execute("edit.pasteDesignLayers", &json!({"html": clipboard_html(&copied_file())})).unwrap(); let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect(); assert_eq!(names(&s, &ids), ["Card", "Dot"]); assert_eq!(s.selection(), ids); @@ -61,9 +74,12 @@ fn layers_paste_into_the_page_and_undo() { } #[test] -fn a_second_paste_repeats_the_figma_layers() { +fn a_second_paste_repeats_the_copied_layers() { let mut s = Session::new(); - s.execute("edit.pasteFromFigma", &json!({"data": B64.encode(copied_file())})).unwrap(); + // `data` may be wrapped and unpadded, like the HTML's base64. + let mut data = B64.encode(copied_file()).trim_end_matches('=').to_string(); + data.insert(8, '\n'); + s.execute("edit.pasteDesignLayers", &json!({ "data": data })).unwrap(); let r = s.execute("edit.paste", &json!({})).unwrap(); let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect(); assert_eq!(names(&s, &ids), ["Card", "Dot"]); @@ -74,7 +90,7 @@ fn pastes_into_the_selected_frame() { let mut s = Session::new(); let host = id(&s.execute("frame.create", &json!({"x": 1000, "y": 0, "width": 800, "height": 600})).unwrap()); s.execute("select.set", &json!({"ids": [host]})).unwrap(); - let r = s.execute("edit.pasteFromFigma", &json!({"html": clipboard_html(&copied_file())})).unwrap(); + let r = s.execute("edit.pasteDesignLayers", &json!({"html": clipboard_html(&copied_file())})).unwrap(); let first = params::id_of(&r["ids"][0]).unwrap(); assert_eq!(s.doc().unwrap().node(first).unwrap().parent, Some(host)); } @@ -83,10 +99,10 @@ fn pastes_into_the_selected_frame() { fn other_content_is_refused_without_changes() { let mut s = Session::new(); let before = s.doc().unwrap().nodes().count(); - assert!(s.execute("edit.pasteFromFigma", &json!({})).is_err()); - assert!(s.execute("edit.pasteFromFigma", &json!({"html": "hi"})).is_err()); - assert!(s.execute("edit.pasteFromFigma", &json!({"data": B64.encode(b"PK not a zip")})).is_err()); - assert!(s.execute("edit.pasteFromFigma", &json!({"data": B64.encode(b"fig-kiwi\x00\x00\x00\x00junk")})).is_err()); + assert!(s.execute("edit.pasteDesignLayers", &json!({})).is_err()); + assert!(s.execute("edit.pasteDesignLayers", &json!({"html": "hi"})).is_err()); + assert!(s.execute("edit.pasteDesignLayers", &json!({"data": B64.encode(b"PK not a zip")})).is_err()); + assert!(s.execute("edit.pasteDesignLayers", &json!({"data": B64.encode(b"fig-kiwi\x00\x00\x00\x00junk")})).is_err()); assert_eq!(s.doc().unwrap().nodes().count(), before); assert!(s.clipboard.nodes.is_empty()); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 908c952a..b124c1e2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1500,6 +1500,8 @@ mod cursor_tests; #[cfg(test)] mod dark_page_text_tests; #[cfg(test)] +mod design_paste_tests; +#[cfg(test)] mod dev_resources_tests; #[cfg(test)] mod devcompare_tests; @@ -1528,8 +1530,6 @@ mod fig_export_tests; #[cfg(test)] mod fig_import_tests; #[cfg(test)] -mod figma_paste_tests; -#[cfg(test)] mod frame_guides_tests; #[cfg(test)] mod frame_label_tests; diff --git a/crates/ui-egui/src/figma_paste_tests.rs b/crates/ui-egui/src/design_paste_tests.rs similarity index 100% rename from crates/ui-egui/src/figma_paste_tests.rs rename to crates/ui-egui/src/design_paste_tests.rs From d07b55104d54a8c487aa6e17c0557dbeaecb7469 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:34:58 +0700 Subject: [PATCH 095/118] #647: paste takes the design path only when the HTML holds readable design data; neutral wording in every catalog --- crates/ui-egui/locales/cs.toml | 2 ++ crates/ui-egui/locales/de.toml | 4 +-- crates/ui-egui/locales/en.toml | 2 +- crates/ui-egui/locales/es.toml | 4 +-- crates/ui-egui/locales/fr.toml | 4 +-- crates/ui-egui/locales/id.toml | 2 ++ crates/ui-egui/locales/ja.toml | 4 +-- crates/ui-egui/locales/ko.toml | 4 +-- crates/ui-egui/locales/pt-BR.toml | 4 +-- crates/ui-egui/locales/ru.toml | 2 ++ crates/ui-egui/locales/sv.toml | 2 ++ crates/ui-egui/locales/tr.toml | 2 ++ crates/ui-egui/locales/zh-Hans.toml | 4 +-- crates/ui-egui/src/design_paste_tests.rs | 34 +++++++++++++++++------- crates/ui-egui/src/lib.rs | 4 +-- crates/ui-egui/src/menus.rs | 12 +++++---- 16 files changed, 59 insertions(+), 31 deletions(-) diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 268ff527..2504193a 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -930,6 +930,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "Vložte odkaz na týmovou knihovnu (odkaz pro prohlížení)" "Paste here" = "Vložit sem" "Paste to replace" = "Vložit a nahradit" +"Pasted design layers; {n} kinds of content were left out" = { one = "Vloženy vrstvy návrhu; vynechán {n} druh obsahu", few = "Vloženy vrstvy návrhu; vynechány {n} druhy obsahu", other = "Vloženy vrstvy návrhu; vynecháno {n} druhů obsahu" } "Pattern" = "Vzor" "Pattern alignment" = "Zarovnání vzoru" "Pattern scale" = "Měřítko vzoru" @@ -1708,6 +1709,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplikovat" "edit.findReplace" = "Najít a nahradit" "edit.paste" = "Vložit" +"edit.pasteDesignLayers" = "Vložit vrstvy návrhu" "edit.pasteOver" = "Vložit přes výběr" "edit.pasteProperties" = "Vložit vlastnosti" "edit.pasteReplace" = "Vložit a nahradit" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index e4749235..3240a642 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -899,7 +899,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "Link einer Teambibliothek einfügen (ein Ansichtslink)" "Paste here" = "Hier einfügen" "Paste to replace" = "Zum Ersetzen einfügen" -"Pasted from Figma; {n} kinds of content were left out" = { one = "Aus Figma eingefügt; {n} Art von Inhalt wurde ausgelassen", other = "Aus Figma eingefügt; {n} Arten von Inhalt wurden ausgelassen" } +"Pasted design layers; {n} kinds of content were left out" = { one = "Designebenen eingefügt; {n} Art von Inhalt wurde ausgelassen", other = "Designebenen eingefügt; {n} Arten von Inhalt wurden ausgelassen" } "Pattern" = "Muster" "Pattern alignment" = "Musterausrichtung" "Pattern scale" = "Musterskalierung" @@ -1655,7 +1655,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplizieren" "edit.findReplace" = "Suchen und ersetzen" "edit.paste" = "Einfügen" -"edit.pasteFromFigma" = "Aus Figma einfügen" +"edit.pasteDesignLayers" = "Designebenen einfügen" "edit.pasteOver" = "Über Auswahl einfügen" "edit.pasteProperties" = "Eigenschaften einfügen" "edit.pasteReplace" = "Zum Ersetzen einfügen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index def0b827..627d0155 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -945,7 +945,7 @@ name = "English" "Paste a team library link (a view link)" = "Paste a team library link (a view link)" "Paste here" = "Paste here" "Paste to replace" = "Paste to replace" -"Pasted from Figma; {n} kinds of content were left out" = { one = "Pasted from Figma; {n} kind of content was left out", other = "Pasted from Figma; {n} kinds of content were left out" } +"Pasted design layers; {n} kinds of content were left out" = { one = "Pasted design layers; {n} kind of content was left out", other = "Pasted design layers; {n} kinds of content were left out" } "Pattern" = "Pattern" "Pattern alignment" = "Pattern alignment" "Pattern scale" = "Pattern scale" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 38c868b6..a6bbb17a 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -946,7 +946,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "Pega el enlace de una biblioteca de equipo (de lectura)" "Paste here" = "Pegar aquí" "Paste to replace" = "Pegar para reemplazar" -"Pasted from Figma; {n} kinds of content were left out" = { one = "Pegado desde Figma; se omitió {n} tipo de contenido", other = "Pegado desde Figma; se omitieron {n} tipos de contenido" } +"Pasted design layers; {n} kinds of content were left out" = { one = "Capas de diseño pegadas; se omitió {n} tipo de contenido", other = "Capas de diseño pegadas; se omitieron {n} tipos de contenido" } "Pattern" = "Patrón" "Pattern alignment" = "Alineación del patrón" "Pattern scale" = "Escala del patrón" @@ -1734,7 +1734,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplicar" "edit.findReplace" = "Buscar y reemplazar" "edit.paste" = "Pegar" -"edit.pasteFromFigma" = "Pegar desde Figma" +"edit.pasteDesignLayers" = "Pegar capas de diseño" "edit.pasteOver" = "Pegar sobre la selección" "edit.pasteProperties" = "Pegar propiedades" "edit.pasteReplace" = "Pegar para reemplazar" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 2f5ae5b9..8074a006 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -893,7 +893,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "Collez le lien d’une bibliothèque d’équipe (un lien de consultation)" "Paste here" = "Coller ici" "Paste to replace" = "Coller pour remplacer" -"Pasted from Figma; {n} kinds of content were left out" = { one = "Collé depuis Figma ; {n} type de contenu a été omis", other = "Collé depuis Figma ; {n} types de contenu ont été omis" } +"Pasted design layers; {n} kinds of content were left out" = { one = "Calques de design collés ; {n} type de contenu a été omis", other = "Calques de design collés ; {n} types de contenu ont été omis" } "Pattern" = "Motif" "Pattern alignment" = "Alignement du motif" "Pattern scale" = "Échelle du motif" @@ -1646,7 +1646,7 @@ status = "draft — needs native review" "edit.duplicate" = "Dupliquer" "edit.findReplace" = "Rechercher et remplacer" "edit.paste" = "Coller" -"edit.pasteFromFigma" = "Coller depuis Figma" +"edit.pasteDesignLayers" = "Coller des calques de design" "edit.pasteOver" = "Coller par-dessus la sélection" "edit.pasteProperties" = "Coller les propriétés" "edit.pasteReplace" = "Coller pour remplacer" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 5de7634d..c39cd73f 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -934,6 +934,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "Tempel link pustaka tim (link akses lihat)" "Paste here" = "Tempel di sini" "Paste to replace" = "Tempel dan ganti" +"Pasted design layers; {n} kinds of content were left out" = { other = "Layer desain ditempel; {n} jenis konten tidak disertakan" } "Pattern" = "Pola" "Pattern alignment" = "Perataan pola" "Pattern scale" = "Skala pola" @@ -1712,6 +1713,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplikat" "edit.findReplace" = "Cari dan ganti" "edit.paste" = "Tempel" +"edit.pasteDesignLayers" = "Tempel layer desain" "edit.pasteOver" = "Tempel di atas pilihan" "edit.pasteProperties" = "Tempel properti" "edit.pasteReplace" = "Tempel dan ganti" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 493bf999..bb6938de 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -939,7 +939,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "チームライブラリのリンク(閲覧リンク)を貼り付け" "Paste here" = "ここに貼り付け" "Paste to replace" = "貼り付けて置換" -"Pasted from Figma; {n} kinds of content were left out" = { other = "Figma から貼り付け済み。{n} 種類のコンテンツは含まれていない" } +"Pasted design layers; {n} kinds of content were left out" = { other = "デザインレイヤーを貼り付け済み。{n} 種類のコンテンツは含まれていない" } "Pattern" = "パターン" "Pattern alignment" = "パターンの配置" "Pattern scale" = "パターンの拡大縮小" @@ -1724,7 +1724,7 @@ status = "draft — needs native review" "edit.duplicate" = "複製" "edit.findReplace" = "検索と置換" "edit.paste" = "貼り付け" -"edit.pasteFromFigma" = "Figma から貼り付け" +"edit.pasteDesignLayers" = "デザインレイヤーを貼り付け" "edit.pasteOver" = "選択範囲の上に貼り付け" "edit.pasteProperties" = "プロパティを貼り付け" "edit.pasteReplace" = "貼り付けて置換" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 253cc54e..65e9296c 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -893,7 +893,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "팀 라이브러리 링크(보기 링크) 붙여 넣기" "Paste here" = "여기에 붙여 넣기" "Paste to replace" = "붙여 넣어 바꾸기" -"Pasted from Figma; {n} kinds of content were left out" = { other = "Figma에서 붙여 넣음. 콘텐츠 {n}종은 제외됨" } +"Pasted design layers; {n} kinds of content were left out" = { other = "디자인 레이어를 붙여 넣음. 콘텐츠 {n}종은 제외됨" } "Pattern" = "패턴" "Pattern alignment" = "패턴 정렬" "Pattern scale" = "패턴 배율" @@ -1646,7 +1646,7 @@ status = "draft — needs native review" "edit.duplicate" = "복제" "edit.findReplace" = "찾기 및 바꾸기" "edit.paste" = "붙여 넣기" -"edit.pasteFromFigma" = "Figma에서 붙여 넣기" +"edit.pasteDesignLayers" = "디자인 레이어 붙여 넣기" "edit.pasteOver" = "선택 항목 위에 붙여 넣기" "edit.pasteProperties" = "속성 붙여 넣기" "edit.pasteReplace" = "붙여 넣어 바꾸기" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 12e7c0b2..b6d21e02 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -892,7 +892,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "Cole o link de uma biblioteca de equipe (um link de visualização)" "Paste here" = "Colar aqui" "Paste to replace" = "Colar para substituir" -"Pasted from Figma; {n} kinds of content were left out" = { one = "Colado do Figma; {n} tipo de conteúdo ficou de fora", other = "Colado do Figma; {n} tipos de conteúdo ficaram de fora" } +"Pasted design layers; {n} kinds of content were left out" = { one = "Camadas de design coladas; {n} tipo de conteúdo ficou de fora", other = "Camadas de design coladas; {n} tipos de conteúdo ficaram de fora" } "Pattern" = "Padrão" "Pattern alignment" = "Alinhamento do padrão" "Pattern scale" = "Escala do padrão" @@ -1645,7 +1645,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplicar" "edit.findReplace" = "Localizar e substituir" "edit.paste" = "Colar" -"edit.pasteFromFigma" = "Colar do Figma" +"edit.pasteDesignLayers" = "Colar camadas de design" "edit.pasteOver" = "Colar sobre a seleção" "edit.pasteProperties" = "Colar propriedades" "edit.pasteReplace" = "Colar para substituir" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 3ab40757..7a64557e 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -931,6 +931,7 @@ name = "Русский" "Paste a team library link (a view link)" = "Вставьте ссылку на командную библиотеку (для просмотра)" "Paste here" = "Вставить здесь" "Paste to replace" = "Вставить с заменой" +"Pasted design layers; {n} kinds of content were left out" = { one = "Слои дизайна вставлены; пропущен {n} тип содержимого", few = "Слои дизайна вставлены; пропущены {n} типа содержимого", many = "Слои дизайна вставлены; пропущено {n} типов содержимого", other = "Слои дизайна вставлены; пропущено {n} типа содержимого" } "Pattern" = "Паттерн" "Pattern alignment" = "Выравнивание паттерна" "Pattern scale" = "Масштаб паттерна" @@ -1709,6 +1710,7 @@ name = "Русский" "edit.duplicate" = "Дублировать" "edit.findReplace" = "Найти и заменить" "edit.paste" = "Вставить" +"edit.pasteDesignLayers" = "Вставить слои дизайна" "edit.pasteOver" = "Вставить поверх выделения" "edit.pasteProperties" = "Вставить свойства" "edit.pasteReplace" = "Вставить с заменой" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index f7b2999d..c3b7f746 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -932,6 +932,7 @@ name = "Svenska" "Paste a team library link (a view link)" = "Klistra in en länk till ett teambibliotek (en visningslänk)" "Paste here" = "Klistra in här" "Paste to replace" = "Klistra in och ersätt" +"Pasted design layers; {n} kinds of content were left out" = { one = "Designlager inklistrade; {n} typ av innehåll utelämnades", other = "Designlager inklistrade; {n} typer av innehåll utelämnades" } "Pattern" = "Mönster" "Pattern alignment" = "Mönsterjustering" "Pattern scale" = "Mönsterskala" @@ -1591,6 +1592,7 @@ name = "Svenska" "edit.duplicate" = "Duplicera" "edit.findReplace" = "Sök och ersätt" "edit.paste" = "Klistra in" +"edit.pasteDesignLayers" = "Klistra in designlager" "edit.pasteOver" = "Klistra in över" "edit.pasteProperties" = "Klistra in egenskaper" "edit.pasteReplace" = "Klistra in och ersätt" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index a2be6b99..531b6526 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -934,6 +934,7 @@ name = "Türkçe" "Paste a team library link (a view link)" = "Bir ekip kitaplığı bağlantısı yapıştırın (görüntüleme bağlantısı)" "Paste here" = "Buraya yapıştır" "Paste to replace" = "Değiştirmek için yapıştır" +"Pasted design layers; {n} kinds of content were left out" = { other = "Tasarım katmanları yapıştırıldı; {n} tür içerik dışarıda bırakıldı" } "Pattern" = "Desen" "Pattern alignment" = "Desen hizalaması" "Pattern scale" = "Desen ölçeği" @@ -1712,6 +1713,7 @@ name = "Türkçe" "edit.duplicate" = "Çoğalt" "edit.findReplace" = "Bul ve değiştir" "edit.paste" = "Yapıştır" +"edit.pasteDesignLayers" = "Tasarım katmanlarını yapıştır" "edit.pasteOver" = "Seçimin üzerine yapıştır" "edit.pasteProperties" = "Özellikleri yapıştır" "edit.pasteReplace" = "Değiştirmek için yapıştır" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 91c251ec..153865bd 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -894,7 +894,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "粘贴团队库链接(查看链接)" "Paste here" = "粘贴到此处" "Paste to replace" = "粘贴以替换" -"Pasted from Figma; {n} kinds of content were left out" = { other = "已从 Figma 粘贴;有 {n} 类内容未包含" } +"Pasted design layers; {n} kinds of content were left out" = { other = "已粘贴设计图层;有 {n} 类内容未包含" } "Pattern" = "图案" "Pattern alignment" = "图案对齐" "Pattern scale" = "图案缩放" @@ -1647,7 +1647,7 @@ status = "draft — needs native review" "edit.duplicate" = "创建副本" "edit.findReplace" = "查找和替换" "edit.paste" = "粘贴" -"edit.pasteFromFigma" = "从 Figma 粘贴" +"edit.pasteDesignLayers" = "粘贴设计图层" "edit.pasteOver" = "粘贴到所选项上方" "edit.pasteProperties" = "粘贴属性" "edit.pasteReplace" = "粘贴以替换" diff --git a/crates/ui-egui/src/design_paste_tests.rs b/crates/ui-egui/src/design_paste_tests.rs index 6147ba5e..ad33e005 100644 --- a/crates/ui-egui/src/design_paste_tests.rs +++ b/crates/ui-egui/src/design_paste_tests.rs @@ -1,4 +1,5 @@ use figcraft_engine::Session; +use figcraft_engine::cmd::edit::{CLIP_CLOSE, CLIP_OPEN}; use serde_json::json; use crate::{FigApp, Services}; @@ -17,11 +18,12 @@ impl Services for Clip { } } -fn figma_html() -> String { +/// The HTML another design app puts on the clipboard for copied layers (a `.fig` canvas inside). +fn copied_html() -> String { let mut s = Session::new(); - s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 40, "height": 20, "name": "From Figma"})).unwrap(); + s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 40, "height": 20, "name": "Copied layer"})).unwrap(); let r = s.execute("file.export", &json!({"format": "fig"})).unwrap(); - format!(r#""#, r["data"].as_str().unwrap()) + format!(r#""#, r["data"].as_str().unwrap()) } fn selected_names(app: &FigApp) -> Vec { @@ -29,12 +31,18 @@ fn selected_names(app: &FigApp) -> Vec { app.session.selection().iter().map(|i| doc.node(*i).unwrap().name.clone()).collect() } +fn pasted_text(app: &FigApp) -> String { + let doc = app.session.doc().unwrap(); + let id = app.session.selection()[0]; + doc.node(id).unwrap().kind.text().unwrap().characters.clone() +} + #[test] -fn paste_takes_layers_copied_in_figma() { - let html = figma_html(); +fn paste_takes_layers_copied_in_another_design_app() { + let html = copied_html(); let mut app = FigApp::new(Session::new(), Box::new(Clip { html, text: Some(String::new()) })); crate::menus::paste(&mut app, None); - assert_eq!(selected_names(&app), ["From Figma"]); + assert_eq!(selected_names(&app), ["Copied layer"]); } #[test] @@ -42,7 +50,15 @@ fn html_from_other_apps_pastes_as_before() { let clip = Clip { html: "Hello".into(), text: Some("Hello".into()) }; let mut app = FigApp::new(Session::new(), Box::new(clip)); crate::menus::paste(&mut app, None); - let doc = app.session.doc().unwrap(); - let id = app.session.selection()[0]; - assert_eq!(doc.node(id).unwrap().kind.text().unwrap().characters, "Hello"); + assert_eq!(pasted_text(&app), "Hello"); +} + +#[test] +fn html_that_only_mentions_the_marker_pastes_its_text() { + let text = format!("Markers {CLIP_OPEN} and {CLIP_CLOSE} explained"); + let clip = Clip { html: format!("

{text}

"), text: Some(text.clone()) }; + let mut app = FigApp::new(Session::new(), Box::new(clip)); + crate::menus::paste(&mut app, None); + assert_eq!(pasted_text(&app), text); + assert!(app.ui.last_error.is_none(), "{:?}", app.ui.last_error); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 04fe3861..e325f4df 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -86,6 +86,8 @@ mod comments_tests; #[cfg(test)] mod cursors_tests; #[cfg(test)] +mod design_paste_tests; +#[cfg(test)] mod dev_mode_tests; #[cfg(test)] mod duplicate_paint_tests; @@ -94,8 +96,6 @@ mod export_preview_tests; #[cfg(test)] mod field_tests; #[cfg(test)] -mod figma_paste_tests; -#[cfg(test)] mod frame_label_tests; #[cfg(test)] mod gap_handle_tests; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 36dfd237..8bd98cc5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -31,14 +31,16 @@ pub const UI_COMMANDS: &[&str] = &[ /// Run a menu item: some open dialogs or use platform services, the rest go to the engine. /// Paste: the system clipboard wins when it holds something newer than our own copy (layers copied -/// in Figma, an image, SVG or text from another app); otherwise our copied layers. `event_text` is -/// the text the platform handed over with the paste event (the browser has no other way to read it). +/// from another design app, an image, SVG or text from another app); otherwise our copied layers. +/// `event_text` is the text the platform handed over with the paste event (the browser has no +/// other way to read it). pub fn paste(app: &mut FigApp, event_text: Option<&str>) { - if let Some(html) = app.services.clipboard_get_html().filter(|h| h.contains("(figma)")) { - if let Some(r) = app.run("edit.pasteFromFigma", json!({"html": html})) { + // Only HTML that holds readable design data takes this path; other HTML pastes as before. + if let Some(html) = app.services.clipboard_get_html().filter(|h| figcraft_engine::cmd::edit::design_clipboard_buffer(h).is_some()) { + if let Some(r) = app.run("edit.pasteDesignLayers", json!({"html": html})) { let left_out = r["warnings"].as_array().map_or(0, Vec::len); if left_out > 0 { - app.toast(trn("Pasted from Figma; {n} kinds of content were left out", left_out, &[])); + app.toast(trn("Pasted design layers; {n} kinds of content were left out", left_out, &[])); } } return; From 25e9be3936f15dd67ae034e4debbbd3832817416 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:49:48 +0700 Subject: [PATCH 096/118] #693: clipboard reads on Linux and the BSDs give up after 2 s A Wayland clipboard read waits on the app that copied with no timeout of its own. The read now runs on a helper thread with its own clipboard handle, so a stuck app can't hang the window. --- apps/figcraft/src/services.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index bd8a4204..56b14d96 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -223,6 +223,28 @@ fn picked(pick: impl Future> + 'static) -> Dial Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) }) } +/// How long a clipboard read may wait on the app that copied (Linux and the BSDs). +#[cfg(all(unix, not(target_os = "macos")))] +const CLIPBOARD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); + +/// Read the clipboard. On Linux and the BSDs the read runs on a helper thread with its own handle +/// and gives up after [`CLIPBOARD_TIMEOUT`]: a Wayland read waits on the app that copied with no +/// timeout of its own, so an app that hangs must not hang the window too. +#[cfg(all(unix, not(target_os = "macos")))] +fn read_clipboard(_: &mut arboard::Clipboard, read: fn(&mut arboard::Clipboard) -> Option) -> Option { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(arboard::Clipboard::new().ok().and_then(|mut c| read(&mut c))); + }); + rx.recv_timeout(CLIPBOARD_TIMEOUT).ok().flatten() +} + +/// Read the clipboard (the system's own reads don't wait on the app that copied). +#[cfg(not(all(unix, not(target_os = "macos"))))] +fn read_clipboard(c: &mut arboard::Clipboard, read: fn(&mut arboard::Clipboard) -> Option) -> Option { + read(c) +} + impl Services for Desktop { fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture { picked(dialog(title).add_filter("Supported files", exts).pick_file()) @@ -239,7 +261,7 @@ impl Services for Desktop { } } fn clipboard_get_text(&mut self) -> Option { - self.clipboard.as_mut()?.get_text().ok() + read_clipboard(self.clipboard.as_mut()?, |c| c.get_text().ok()) } fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) { if let Some(c) = self.clipboard.as_mut() { @@ -247,7 +269,7 @@ impl Services for Desktop { } } fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec)> { - let img = self.clipboard.as_mut()?.get_image().ok()?; + let img = read_clipboard(self.clipboard.as_mut()?, |c| c.get_image().ok())?; let (w, h) = (u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?); // Refuse absurd sizes (and mismatched buffers) rather than allocate. if w == 0 || h == 0 || u64::from(w) * u64::from(h) > 100_000_000 || img.bytes.len() != (w as usize) * (h as usize) * 4 { From ef684c5df4bbde407400194260aa537c2636cbc5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:58:06 +0700 Subject: [PATCH 097/118] #691: leave the parity docs to the maintainers' docs PR --- docs/ui-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a8dfab69..62001715 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -48,7 +48,7 @@ interaction feel not yet compared. | Item | Status | Notes | |---|---|---| -| Click, Shift-click, marquee, Shift-marquee toggle | done | a marquee selects what it touches; from a top-level frame's background, the layers inside it; locked and hidden layers are skipped; Shift-click takes a selected layer out on release, so a Shift-drag on it moves the selection along one axis instead (`selection_behaviour_tests`) | +| Click, Shift-click, marquee, Shift-marquee toggle | done | a marquee selects what it touches; from a top-level frame's background, the layers inside it; locked and hidden layers are skipped (`selection_behaviour_tests`) | | Deep select (Cmd/Ctrl-click, Cmd/Ctrl-marquee) | done | | | Select layer menu (every layer under the click) | done | | | Enter / Shift+Enter (children / parent), Tab / Shift+Tab (siblings) | done | Tab renames the next layer while renaming | From 22a821c7420b8a7ba3bf937adea829b778150756 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 15:03:02 +0700 Subject: [PATCH 098/118] #657: leave the roadmap to the maintainers' docs PR --- docs/roadmap.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 680c5b68..9e9eaec9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (points dropped on a point merge (#401); community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -73,7 +73,6 @@ blockers. | Date | Change | Summary | |---|---|---| -| 2026-10-16 | minor | Points dropped on a point merge (M4, #401): in vector edit mode a point dragged onto another point of its path snapped there but stayed a separate point, so the path couldn't be closed or joined by dragging. Dropped within the point hit radius (6 px) of another point, a single dragged point now merges into it in the same undo step as the move, and that point stays selected; an open path's end dropped on its start closes it. Join (Cmd+J) still merges selected points that overlap. Regression test in the vector edit tests. Numbers unchanged | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-14 | minor | M7: the Small caps button in Type settings showed a box where the UI font has no small-capital `ɢ` (builds without Inter use egui's fonts); its sample is now a capital and a smaller capital, drawn from ordinary letters every UI font has. Numbers unchanged | | 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | From 58de7d4daba16ab95393bb0732b6ba4afffbed94 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 15:06:37 +0700 Subject: [PATCH 099/118] #657: a drop that would leave no segment only moves the point Dropping one end of a 2-point line on the other left a 1-point vector. --- crates/engine/src/vectoredit/pointer.rs | 8 +++++++- crates/engine/src/vectoredit/tests.rs | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/vectoredit/pointer.rs index a42b0603..6667a209 100644 --- a/crates/engine/src/vectoredit/pointer.rs +++ b/crates/engine/src/vectoredit/pointer.rs @@ -1082,10 +1082,16 @@ impl Session { else { return Ok(()); }; + let mut merged = net.clone(); + ops::merge_vertices(&mut merged, keep, v); + // A merge that would leave no segment (a 2-point line's ends dropped together) only moves. + if merged.segments.is_empty() { + return Ok(()); + } // The preview starts again from the document before the drag: bring the moved layer. self.preview(|doc, _| { *doc.get_mut(id)? = moved; - edit(doc, id, |net| ops::merge_vertices(net, keep, v)) + edit(doc, id, |net| *net = merged) })?; set_state(self, |e| { e.vertices = vec![if keep > v { keep - 1 } else { keep }]; diff --git a/crates/engine/src/vectoredit/tests.rs b/crates/engine/src/vectoredit/tests.rs index 63469d67..0eba28e3 100644 --- a/crates/engine/src/vectoredit/tests.rs +++ b/crates/engine/src/vectoredit/tests.rs @@ -300,6 +300,14 @@ fn a_point_dropped_on_another_merges_into_it() { ex(&mut s, "vector.edit.select", json!({"vertices": []})); drag(&mut s, (0.0, 100.0), (0.0, 50.0)); assert_eq!(net(&s, id).vertices.len(), 4); + // A 2-point line's end dropped on its other end only moves: no segment would be left. + let mut s = Session::new(); + let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0]]})); + let id = crate::params::id_of(&r["id"]).unwrap(); + ex(&mut s, "vector.edit.begin", json!({"id": id})); + drag(&mut s, (100.0, 0.0), (2.0, 0.0)); + let n = net(&s, id); + assert_eq!((n.vertices.len(), n.segments.len()), (2, 1), "still a line: {n:?}"); } #[test] From bbfc408ab8ceb4553fe1cfd9b0ee8c9f5b935b4d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 15:06:47 +0700 Subject: [PATCH 100/118] #654: leave the roadmap to the maintainers' docs PR --- docs/roadmap.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 8acce139..9e9eaec9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Flatten keeps open paths (#377); community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -73,7 +73,6 @@ blockers. | Date | Change | Summary | |---|---|---| -| 2026-10-16 | minor | Flatten keeps open paths (M4, #377): Flatten (Cmd+E, Alt+Shift+F) and Join selection (Cmd+J) built the result from each layer's fill outline, so open paths, which have none, were dropped: two pen paths flattened to "nothing to flatten", and next to a closed shape they vanished. Vectors now bring their own network (open paths, unfilled loops and each region's winding stay), mapped into the parent's space; other layers still flatten to their outline. Regression test in the shapes behaviour tests. Numbers unchanged | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-14 | minor | M7: the Small caps button in Type settings showed a box where the UI font has no small-capital `ɢ` (builds without Inter use egui's fonts); its sample is now a capital and a smaller capital, drawn from ordinary letters every UI font has. Numbers unchanged | | 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | From 426217327b1a569837cb771a1fa86db91d7d5a6b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 15:11:23 +0700 Subject: [PATCH 101/118] #647: text copied in another app after a design paste pastes as text again --- crates/ui-egui/src/design_paste_tests.rs | 23 +++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 2 ++ 2 files changed, 25 insertions(+) diff --git a/crates/ui-egui/src/design_paste_tests.rs b/crates/ui-egui/src/design_paste_tests.rs index ad33e005..3174275a 100644 --- a/crates/ui-egui/src/design_paste_tests.rs +++ b/crates/ui-egui/src/design_paste_tests.rs @@ -62,3 +62,26 @@ fn html_that_only_mentions_the_marker_pastes_its_text() { assert_eq!(pasted_text(&app), text); assert!(app.ui.last_error.is_none(), "{:?}", app.ui.last_error); } + +/// A clipboard whose contents the test replaces between pastes. +struct SharedClip(std::rc::Rc>); + +impl Services for SharedClip { + fn clipboard_get_html(&mut self) -> Option { + Some(self.0.borrow().html.clone()) + } + fn clipboard_get_text(&mut self) -> Option { + self.0.borrow().text.clone() + } +} + +#[test] +fn text_copied_after_a_design_paste_pastes_as_text() { + let clip = std::rc::Rc::new(std::cell::RefCell::new(Clip { html: copied_html(), text: None })); + let mut app = FigApp::new(Session::new(), Box::new(SharedClip(clip.clone()))); + crate::menus::paste(&mut app, None); + assert_eq!(selected_names(&app), ["Copied layer"]); + *clip.borrow_mut() = Clip { html: String::new(), text: Some("Fresh text".into()) }; + crate::menus::paste(&mut app, None); + assert_eq!(pasted_text(&app), "Fresh text"); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8bd98cc5..8e86953c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -38,6 +38,8 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>) { // Only HTML that holds readable design data takes this path; other HTML pastes as before. if let Some(html) = app.services.clipboard_get_html().filter(|h| figcraft_engine::cmd::edit::design_clipboard_buffer(h).is_some()) { if let Some(r) = app.run("edit.pasteDesignLayers", json!({"html": html})) { + // The pasted layers are now our copy: text another app puts on the clipboard later is newer. + app.ui.last_copy = Some(app.services.clipboard_get_text().unwrap_or_default()); let left_out = r["warnings"].as_array().map_or(0, Vec::len); if left_out > 0 { app.toast(trn("Pasted design layers; {n} kinds of content were left out", left_out, &[])); From 45b154e40b2217af232fe7ce1c5c573a52077784 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 15:20:08 +0700 Subject: [PATCH 102/118] #707: drop the unrelated .gitignore line and the parity doc edit --- .gitignore | 2 -- docs/ui-parity.md | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 3709c74a..928d6b49 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,3 @@ plans/ /log/ .DS_Store *.profraw - -plugins/target/ diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 4a7bf45b..62001715 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -19,7 +19,7 @@ interaction feel not yet compared. | Light and dark themes | done | colours from `theme::Tokens`; a file or a page made while the dark theme shows gets a `#1E1E1E` page (`#F5F5F5` in the light theme), its own from then on: changing the theme repaints no page and opened files keep theirs; the file the app opens with takes the theme once it is known; the CLI, MCP and exports have no theme and keep `#F5F5F5`; text drawn with the Text tool is white on a dark page and black on a light one (so is text a command makes in the app without naming a fill; headless sessions keep black) (`theme_pages_tests`, `dark_page_text_tests`) | | Minimize UI (Cmd+\\) | done | | | Resizable panels | done | each panel's edge drags its width (left 200–480, right 240–480, 240 by default; a double-click on the edge or View ▸ Panels ▸ Reset panel widths puts the default back); the widths are each window's own, the main window's are kept in the preferences when a drag ends and come back at the next start, a new window opens with those of the window it comes from; Minimize UI hides the panels and brings them back as they were; the view keeps its centre and zoom while a panel is resized; in a window too small for them the panels give way so the canvas keeps 160 px (the left one first), down to the smallest window (800×500) (`panel_width_tests`). Not compared with Figma's widths; no snap points | -| Actions palette (Cmd+K, Cmd+/) | done | tabs (All, Assets, Plugins & widgets), Recents kept in the preferences, commands grouped by menu, this file's components (placed at the view centre) and styles (applied to the selection), installed plugins and widgets with letter tiles; rows carry icons and shortcut hints (`palette_tests`) | +| Actions palette (Cmd+K, Cmd+/) | done | commands; no assets search parity | | Context menus | done | canvas and layers; Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key isn't delivered by the windowing layer (egui 0.36 has no key for it) | | Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open files, recent files (cards with the picture each file carries on its page colour, the file's icon, name and "Edited ... ago" or "File not found", full path on hover; or a Name, Last modified and Created table with sortable headers; sorted by name, creation or last change either way; grid or list and the sort kept in the preferences, `ui.set {homeRecentsView, homeRecentsSort, homeRecentsOrder}`; a click selects, a double-click or Enter opens, arrow keys move the selection; Open, Show in folder and Remove from recents on right-click), a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder | | Main menu from the logo | done | generated from `catalog.rs`; popups sized to content | From 3a8e6e2860c082ec32df56718caa9af73ffc75ba Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 15:20:08 +0700 Subject: [PATCH 103/118] #707: search every command, ranked; a highlight for Enter; recents from the palette only - A search covers every command again (the engine's and the UI's own), grouped by menu where one lists it and under Other otherwise, best matches first (prefix, then shortest), at most 60. Without a query the menus' items show as before. - Up and Down move a visible highlight (the pointer moves it too); Enter runs the highlighted row, not an invisible first one. - Only runs from the palette are kept as Recents; menus, shortcuts and context menus no longer write the preferences. Recents match the translated label. - The Plugins menu's items (run last, manage, import) show with the plugins. - The search keeps its hint, so every catalog keeps its translation. - The panel sits above the toolbar pill's own rect (toolbar::pill_rect); one set_palette/toggle_palette opens it from every entry point. - Clippy: needless borrows in the toolbar. --- crates/ui-egui/locales/cs.toml | 1 + crates/ui-egui/locales/de.toml | 1 + crates/ui-egui/locales/en.toml | 3 +- crates/ui-egui/locales/es.toml | 5 +- crates/ui-egui/locales/fr.toml | 1 + crates/ui-egui/locales/id.toml | 1 + crates/ui-egui/locales/ja.toml | 1 + crates/ui-egui/locales/ko.toml | 1 + crates/ui-egui/locales/pt-BR.toml | 1 + crates/ui-egui/locales/ru.toml | 1 + crates/ui-egui/locales/sv.toml | 1 + crates/ui-egui/locales/tr.toml | 1 + crates/ui-egui/locales/zh-Hans.toml | 1 + crates/ui-egui/src/control.rs | 4 +- crates/ui-egui/src/lib.rs | 13 +- crates/ui-egui/src/menus.rs | 376 ++++++++++++++-------------- crates/ui-egui/src/palette_tests.rs | 86 +++++-- crates/ui-egui/src/plugins_ui.rs | 2 - crates/ui-egui/src/toolbar.rs | 21 +- 19 files changed, 291 insertions(+), 230 deletions(-) diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index e86da423..268ff527 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -1133,6 +1133,7 @@ status = "draft — needs native review" "Scroll with parent" = "Posouvat s nadřazeným prvkem" "Search plugins" = "Hledat pluginy" "Search" = "Hledat" +"Search actions, layers and tools" = "Hledat akce, vrstvy a nástroje" "Search assets" = "Hledat zdroje" "Search comments" = "Hledat komentáře" "Search fonts" = "Hledat písma" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 862527e4..cd1e3482 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1096,6 +1096,7 @@ status = "draft — needs native review" "Scroll to layer" = "Zur Ebene scrollen" "Scroll with parent" = "Mit übergeordnetem Element scrollen" "Search" = "Suchen" +"Search actions, layers and tools" = "Aktionen, Ebenen und Werkzeuge suchen" "Search assets" = "Assets suchen" "Search comments" = "Kommentare suchen" "Search fonts" = "Schriften suchen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 8894ece1..514b6975 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -913,6 +913,7 @@ name = "English" "Orange" = "Orange" "Order" = "Order" "Other apps may not open every .fig file exported here." = "Other apps may not open every .fig file exported here." +"Other" = "Other" "Others lose access and keep their copy; the file stays on the server for you" = "Others lose access and keep their copy; the file stays on the server for you" "Others type this under Share ▸ Join with a code" = "Others type this under Share ▸ Join with a code" "Outline stroke" = "Outline stroke" @@ -1152,7 +1153,7 @@ name = "English" "Scroll with parent" = "Scroll with parent" "Search plugins" = "Search plugins" "Search" = "Search" -"Search actions, assets and plugins" = "Search actions, assets and plugins" +"Search actions, layers and tools" = "Search actions, layers and tools" "Search assets" = "Search assets" "Search comments" = "Search comments" "Search fonts" = "Search fonts" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index d5e353cf..55e228dd 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -914,6 +914,7 @@ status = "draft — needs native review" "Orange" = "Naranja" "Order" = "Orden" "Other apps may not open every .fig file exported here." = "Puede que otras aplicaciones no abran todos los archivos .fig exportados aquí." +"Other" = "Otros" "Others lose access and keep their copy; the file stays on the server for you" = "Los demás pierden el acceso y conservan su copia; el archivo se queda en el servidor para ti" "Others type this under Share ▸ Join with a code" = "Los demás lo escriben en Compartir ▸ Unirse con un código" "Outline stroke" = "Contornear trazo" @@ -971,7 +972,7 @@ status = "draft — needs native review" "Plugin" = "Plugin" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Los plugins se ejecutan en el entorno aislado de FigCraft, sin archivos, red ni reloj. Instala solo plugins de confianza." "Plugins" = "Plugins" -"Plugins & widgets" = "Plugins & widgets" +"Plugins & widgets" = "Plugins y widgets" "Plus darker" = "Más oscuro" "Plus lighter" = "Más claro" "Point" = "Punto" @@ -1153,8 +1154,8 @@ status = "draft — needs native review" "Scroll with parent" = "Desplazar con el padre" "Search plugins" = "Buscar plugins" "Search" = "Buscar" +"Search actions, layers and tools" = "Buscar acciones, capas y herramientas" "Search assets" = "Buscar recursos" -"Search actions, assets and plugins" = "Buscar acciones, recursos y plugins" "Search comments" = "Buscar comentarios" "Search fonts" = "Buscar fuentes" "Search layers" = "Buscar capas" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 2c1fbc45..7fafd3c9 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -1090,6 +1090,7 @@ status = "draft — needs native review" "Scroll to layer" = "Défiler jusqu’au calque" "Scroll with parent" = "Défiler avec le parent" "Search" = "Rechercher" +"Search actions, layers and tools" = "Rechercher des actions, des calques et des outils" "Search assets" = "Rechercher des ressources" "Search comments" = "Rechercher des commentaires" "Search fonts" = "Rechercher des polices" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 53825747..5de7634d 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -1137,6 +1137,7 @@ status = "draft — needs native review" "Scroll with parent" = "Gulir bersama induk" "Search plugins" = "Cari plugin" "Search" = "Cari" +"Search actions, layers and tools" = "Cari tindakan, layer, dan alat" "Search assets" = "Cari aset" "Search comments" = "Cari komentar" "Search fonts" = "Cari font" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 670f84fc..4d4d6d34 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -1140,6 +1140,7 @@ status = "draft — needs native review" "Scroll to layer" = "レイヤーまでスクロール" "Scroll with parent" = "親と一緒にスクロール" "Search" = "検索" +"Search actions, layers and tools" = "アクション、レイヤー、ツールを検索" "Search assets" = "アセットを検索" "Search comments" = "コメントを検索" "Search fonts" = "フォントを検索" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 1f7bed0c..ec732905 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1090,6 +1090,7 @@ status = "draft — needs native review" "Scroll to layer" = "레이어로 스크롤" "Scroll with parent" = "부모와 함께 스크롤" "Search" = "검색" +"Search actions, layers and tools" = "액션, 레이어, 도구 검색" "Search assets" = "에셋 검색" "Search comments" = "댓글 검색" "Search fonts" = "글꼴 검색" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 2fb61d12..c2189de8 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -1089,6 +1089,7 @@ status = "draft — needs native review" "Scroll to layer" = "Rolar até a camada" "Scroll with parent" = "Rolar com o pai" "Search" = "Pesquisar" +"Search actions, layers and tools" = "Pesquisar ações, camadas e ferramentas" "Search assets" = "Pesquisar recursos" "Search comments" = "Pesquisar comentários" "Search fonts" = "Pesquisar fontes" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 78a8afd6..3ab40757 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -1134,6 +1134,7 @@ name = "Русский" "Scroll with parent" = "Прокручивать с родителем" "Search plugins" = "Поиск плагинов" "Search" = "Поиск" +"Search actions, layers and tools" = "Поиск действий, слоёв и инструментов" "Search assets" = "Поиск ресурсов" "Search comments" = "Поиск комментариев" "Search fonts" = "Поиск шрифтов" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index a54c02e7..f7b2999d 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -1135,6 +1135,7 @@ name = "Svenska" "Scroll with parent" = "Rulla med överordnat element" "Search plugins" = "Sök plugin-program" "Search" = "Sök" +"Search actions, layers and tools" = "Sök åtgärder, lager och verktyg" "Search assets" = "Sök tillgångar" "Search comments" = "Sök i kommentarer" "Search fonts" = "Sök typsnitt" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 43603287..a2be6b99 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -1137,6 +1137,7 @@ name = "Türkçe" "Scroll with parent" = "Üst öğeyle birlikte kaydır" "Search plugins" = "Eklentilerde ara" "Search" = "Ara" +"Search actions, layers and tools" = "Eylem, katman ve araç ara" "Search assets" = "Varlıklarda ara" "Search comments" = "Yorumlarda ara" "Search fonts" = "Yazı tiplerinde ara" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index a42360e4..af4cf653 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -1091,6 +1091,7 @@ status = "draft — needs native review" "Scroll to layer" = "滚动到图层" "Scroll with parent" = "随父级滚动" "Search" = "搜索" +"Search actions, layers and tools" = "搜索操作、图层和工具" "Search assets" = "搜索资源" "Search comments" = "搜索评论" "Search fonts" = "搜索字体" diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 6e55c8a4..9a97081f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -414,9 +414,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul crate::home_recents::Sort { descending: order == "desc", ..sort }.store(&mut app.session.prefs); } if let Some(h) = p.get("palette").and_then(Value::as_bool) { - app.ui.palette = if h { Some(String::new()) } else { None }; - app.ui.palette_tab = crate::menus::PaletteTab::All; - app.ui.palette_armed = true; + crate::menus::set_palette(&mut app.ui, h); } // true / false, or a top-level menu to open it at ("Plugins"). match p.get("mainMenu") { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 34f6ac0d..4a047c89 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -408,6 +408,8 @@ pub struct UiState { pub palette: Option, /// The Actions palette's tab. pub palette_tab: menus::PaletteTab, + /// The palette row Up and Down are on (Enter runs it). + pub palette_hi: usize, /// The palette ignores one outside click after opening (the click that opened it). pub palette_armed: bool, /// This frame's Actions toolbar button rect: presses on it toggle, never dismiss. @@ -542,6 +544,7 @@ impl Default for UiState { main_menu: false, palette: None, palette_tab: menus::PaletteTab::All, + palette_hi: 0, palette_armed: false, actions_rect: None, variables: None, @@ -799,15 +802,7 @@ impl FigApp { } "dialog.preferences" => self.ui.preferences = true, "panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel, - "palette.actions" => { - if self.ui.palette.is_some() { - self.ui.palette = None; - } else { - self.ui.palette = Some(String::new()); - self.ui.palette_tab = menus::PaletteTab::All; - self.ui.palette_armed = true; - } - } + "palette.actions" => menus::toggle_palette(&mut self.ui), "panel.design" => self.ui.right_tab = RightTab::Design, "panel.prototype" => self.ui.right_tab = RightTab::Prototype, "panel.designPrototype" => { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 73bee5bc..e54f0090 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -7,8 +7,8 @@ use serde_json::json; use crate::FigApp; use crate::a11y; -use crate::icons::{self, Icon}; use crate::i18n::{self, tr, tr_noop, trf}; +use crate::icons::{self, Icon}; use crate::theme::{Tokens, regular}; use crate::toolbar::pretty_shortcut; @@ -82,7 +82,7 @@ pub enum PaletteTab { Plugins, } -/// Remember a ran action for the palette's Recents (newest first, capped). +/// Remember an action run from the palette for its Recents (newest first, capped). pub fn record_recent(app: &mut FigApp, key: &str) { let r = &mut app.session.prefs.recent_actions; if r.first().is_some_and(|f| f == key) { @@ -94,7 +94,6 @@ pub fn record_recent(app: &mut FigApp, key: &str) { } pub fn invoke(app: &mut FigApp, id: &str) { - record_recent(app, &format!("command:{id}")); match id { "file.open" => app.open_dialog(), "plugin.import" => crate::plugins_ui::import_dialog(app), @@ -434,7 +433,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { t.menu_dim, ); if resp.clicked() { - app.ui.palette = Some(String::new()); + set_palette(&mut app.ui, true); close = true; } ui.add_space(6.0); @@ -657,19 +656,31 @@ fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2, t: &Tokens) /// The Actions palette's content height (the frame adds its own 8 px margins). const PALETTE_H: f32 = 450.0; -/// The Actions palette: search over commands, assets and plugins, in Figma's shape (tabs, -/// recents, grouped sections). Rows run on click; Enter runs the first visible row. +/// Open the Actions palette afresh (empty search, All tab, first row highlighted), or close it. +pub fn set_palette(ui: &mut crate::UiState, open: bool) { + ui.palette = open.then(String::new); + ui.palette_tab = PaletteTab::All; + ui.palette_hi = 0; + ui.palette_armed = open; +} + +/// Open the Actions palette afresh, or close it when it's open (⌘K, the toolbar's Actions button). +pub fn toggle_palette(ui: &mut crate::UiState) { + let open = ui.palette.is_none(); + set_palette(ui, open); +} + +/// The Actions palette: search over commands, assets and plugins, with tabs, recents and grouped +/// sections, floating above the toolbar. Rows run on click; Up and Down move the highlight and +/// Enter runs the highlighted row. fn palette(app: &mut FigApp, ctx: &egui::Context) { let Some(mut q) = app.ui.palette.clone() else { return }; let t = Tokens::get(ctx); // Same centre and width as the toolbar pill (it centres on the canvas, not the window), // floating 8 px above it. Fixed size: switching tabs never resizes the panel. let bar_w = crate::toolbar::main_width(); - let canvas = app.canvas.rect; - let area = Rect::from_min_size( - pos2(canvas.center().x - bar_w / 2.0, canvas.max.y - 68.0 - (PALETTE_H + 16.0)), - vec2(bar_w, PALETTE_H + 16.0), - ); + let bar = crate::toolbar::pill_rect(app.canvas.rect, bar_w); + let area = Rect::from_min_max(pos2(bar.min.x, bar.min.y - 8.0 - (PALETTE_H + 16.0)), pos2(bar.max.x, bar.min.y - 8.0)); let mut run: Option = None; let mut close = false; // A press anywhere outside closes the panel. The opening press is spared once, and the @@ -684,135 +695,93 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) { } else if !pressed { app.ui.palette_armed = false; } - egui::Area::new(Id::new("palette")).fixed_pos(area.min).order(egui::Order::Foreground).show( - ctx, - |ui| { - egui::Frame::NONE - .fill(t.panel) - .corner_radius(CornerRadius::same(13)) - .stroke(Stroke::new(1.0, t.border)) - .shadow(egui::Shadow { offset: [0, 8], blur: 32, spread: 0, color: t.shadow }) - .inner_margin(egui::Margin::same(8)) - .show(ui, |ui| { - // Same outer width as the toolbar pill, and a fixed height: switching tabs - // never resizes the panel. - let inner = crate::toolbar::main_width() - 16.0; - ui.set_width(inner); - ui.set_height(PALETTE_H); - let r = ui.add( - egui::TextEdit::singleline(&mut q) - .hint_text(tr("Search actions, assets and plugins")) - .desired_width(inner - 16.0) - .font(regular(14.0)) - .frame(egui::Frame::NONE.inner_margin(egui::Margin::same(8))), - ); - r.request_focus(); - crate::widgets::select_all_on_focus(ui, &r, &q); - ui.add_space(4.0); - // Tabs: All, Assets, Plugins & widgets. - let tabs = [(PaletteTab::All, tr("All")), (PaletteTab::Assets, tr("Assets")), (PaletteTab::Plugins, tr("Plugins & widgets"))]; - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing = vec2(4.0, 0.0); - for (tab, label) in tabs { - let active = app.ui.palette_tab == tab; - // The frame stays on for every tab (transparent when idle) so the - // active pill never moves its neighbours. - let resp = ui.add( - egui::Button::new(egui::RichText::new(label).size(12.0).color(if active { t.text } else { t.text_secondary })) - .frame(true) - .fill(if active { t.hover } else { Color32::TRANSPARENT }) - .corner_radius(CornerRadius::same(6)), - ); - if resp.clicked() { - app.ui.palette_tab = tab; - } - } - }); - ui.add_space(4.0); - let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); - let mut first: Option = None; - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - let tab = app.ui.palette_tab; - if tab == PaletteTab::All { - let recents: Vec = app - .session - .prefs - .recent_actions - .clone() - .iter() - .filter_map(|key| recent_row(app, key, &q)) - .collect(); - if !recents.is_empty() { - section(ui, &t, tr("Recents"), |ui| { - for row in &recents { - first.get_or_insert_with(|| row.key.clone()); - if draw_row(ui, &t, row) { - run = Some(row.key.clone()); - } - } - }); - } - } - if tab != PaletteTab::Plugins { - for (group, rows) in command_sections(&q) { - if tab == PaletteTab::Assets { - continue; - } - section(ui, &t, &group, |ui| { - for row in rows { - first.get_or_insert_with(|| row.key.clone()); - if draw_row(ui, &t, &row) { - run = Some(row.key); - } - } - }); - } - } - if tab != PaletteTab::Assets { - let plugins = plugin_rows(app, &q); - let widgets = widget_rows(app, &q); - if !plugins.is_empty() { - section(ui, &t, tr("Plugins"), |ui| { - for row in plugins { - first.get_or_insert_with(|| row.key.clone()); - if draw_row(ui, &t, &row) { - run = Some(row.key); - } - } - }); - } - if !widgets.is_empty() { - section(ui, &t, tr("Widgets"), |ui| { - for row in widgets { - first.get_or_insert_with(|| row.key.clone()); - if draw_row(ui, &t, &row) { - run = Some(row.key); - } - } - }); - } - } - if tab == PaletteTab::Assets { - for (group, rows) in asset_sections(app, &q) { - section(ui, &t, &group, |ui| { - for row in rows { - first.get_or_insert_with(|| row.key.clone()); - if draw_row(ui, &t, &row) { - run = Some(row.key); - } - } - }); - } - } - }); - if enter { - if let Some(key) = first { - run = Some(key); + // Taken before the search field sees them (it would move its cursor). + let step = ctx.input_mut(|i| { + i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) as i64 + - i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) as i64 + }); + egui::Area::new(Id::new("palette")).fixed_pos(area.min).order(egui::Order::Foreground).show(ctx, |ui| { + egui::Frame::NONE + .fill(t.panel) + .corner_radius(CornerRadius::same(13)) + .stroke(Stroke::new(1.0, t.border)) + .shadow(egui::Shadow { offset: [0, 8], blur: 32, spread: 0, color: t.shadow }) + .inner_margin(egui::Margin::same(8)) + .show(ui, |ui| { + // Same outer width as the toolbar pill, and a fixed height: switching tabs + // never resizes the panel. + let inner = bar_w - 16.0; + ui.set_width(inner); + ui.set_height(PALETTE_H); + let r = ui.add( + egui::TextEdit::singleline(&mut q) + .hint_text(tr("Search actions, layers and tools")) + .desired_width(inner - 16.0) + .font(regular(14.0)) + .frame(egui::Frame::NONE.inner_margin(egui::Margin::same(8))), + ); + r.request_focus(); + crate::widgets::select_all_on_focus(ui, &r, &q); + // A new search starts again at the first row. + if r.changed() { + app.ui.palette_hi = 0; + } + ui.add_space(4.0); + // Tabs: All, Assets, Plugins & widgets. + let tabs = [(PaletteTab::All, tr("All")), (PaletteTab::Assets, tr("Assets")), (PaletteTab::Plugins, tr("Plugins & widgets"))]; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = vec2(4.0, 0.0); + for (tab, label) in tabs { + let active = app.ui.palette_tab == tab; + // The frame stays on for every tab (transparent when idle) so the + // active pill never moves its neighbours. + let resp = ui.add( + egui::Button::new(egui::RichText::new(label).size(12.0).color(if active { t.text } else { t.text_secondary })) + .frame(true) + .fill(if active { t.hover } else { Color32::TRANSPARENT }) + .corner_radius(CornerRadius::same(6)), + ); + if resp.clicked() { + app.ui.palette_tab = tab; + app.ui.palette_hi = 0; } } }); - }, - ); + ui.add_space(4.0); + let sections = palette_sections(app, &q, app.ui.palette_tab); + let count = sections.iter().map(|(_, rows)| rows.len()).sum::(); + // At either end it stays where it was. + let hi = (app.ui.palette_hi as i64 + step).clamp(0, count.saturating_sub(1) as i64) as usize; + app.ui.palette_hi = hi; + // The pointer moving over a row moves the highlight there. + let pointer_moved = ui.input(|i| i.pointer.delta() != egui::Vec2::ZERO); + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + let mut i = 0; + for (name, rows) in §ions { + section(ui, &t, name, |ui| { + for row in rows { + let resp = draw_row(ui, &t, row, i == hi); + if resp.clicked() { + run = Some(row.key.clone()); + } + if pointer_moved && resp.hovered() && i != hi { + app.ui.palette_hi = i; + // Drawn there on the next frame, even if the pointer stops now. + ui.ctx().request_repaint(); + } + if step != 0 && i == hi { + resp.scroll_to_me(None); + } + i += 1; + } + }); + } + }); + if run.is_none() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + run = sections.iter().flat_map(|(_, rows)| rows).nth(hi).map(|row| row.key.clone()); + } + }); + }); if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { close = true; } @@ -825,10 +794,32 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) { let _ = StrokeKind::Inside; } +/// The rows a palette tab shows for the query, by section: All has Recents, the commands by menu, +/// then plugins and widgets; Assets this file's components and styles; Plugins & widgets those. +pub(crate) fn palette_sections(app: &FigApp, q: &str, tab: PaletteTab) -> Vec<(String, Vec)> { + let mut out = match tab { + PaletteTab::All => { + let recents = app.session.prefs.recent_actions.iter().filter_map(|key| recent_row(app, key, q)).collect(); + let mut out = vec![(tr("Recents").to_string(), recents)]; + out.extend(command_sections(q)); + out + } + PaletteTab::Assets => asset_sections(app, q), + PaletteTab::Plugins => Vec::new(), + }; + if tab != PaletteTab::Assets { + out.push((tr("Plugins").to_string(), plugin_rows(app, q))); + out.push((tr("Widgets").to_string(), widget_rows(app, q))); + } + out.retain(|(_, rows)| !rows.is_empty()); + out +} + /// Run a palette row: `command:`, `plugin:/`, `widget:`, /// `component:` or `style:`. -fn run_action(app: &mut FigApp, key: &str) { +pub(crate) fn run_action(app: &mut FigApp, key: &str) { if let Some(id) = key.strip_prefix("command:") { + record_recent(app, key); invoke(app, id); } else if let Some(rest) = key.strip_prefix("plugin:") { if let Some((plugin, command)) = rest.split_once('/') { @@ -869,14 +860,14 @@ fn section(ui: &mut Ui, t: &Tokens, name: &str, rows: impl FnOnce(&mut Ui)) { rows(ui); } -/// Draw one row (a letter tile for plugins, else just the label) with its right-side hint. -/// True when clicked. -fn draw_row(ui: &mut Ui, t: &Tokens, row: &ActionRow) -> bool { +/// Draw one row (a letter tile for plugins, else just the label) with its right-side hint, +/// `highlighted` when Enter runs it. +fn draw_row(ui: &mut Ui, t: &Tokens, row: &ActionRow, highlighted: bool) -> egui::Response { let w = ui.available_width(); let (r, resp) = ui.allocate_exact_size(vec2(w, 34.0), Sense::click()); - // The hover pill stays inside the row: it never touches the panel's edges. + // The highlight pill stays inside the row: it never touches the panel's edges. let pill = r.shrink2(vec2(4.0, 1.0)); - if resp.hovered() { + if highlighted { ui.painter().rect_filled(pill, CornerRadius::same(6), t.hover); } let mut label_x = r.min.x + 10.0; @@ -888,7 +879,7 @@ fn draw_row(ui: &mut Ui, t: &Tokens, row: &ActionRow) -> bool { } ui.painter().text(pos2(label_x, r.center().y), Align2::LEFT_CENTER, &row.label, regular(13.0), t.text); ui.painter().text(pos2(r.max.x - 10.0, r.center().y), Align2::RIGHT_CENTER, &row.hint, regular(11.0), t.text_secondary); - resp.clicked() + resp } /// Whether `label` (or `extra`) matches the query, in any case. @@ -896,28 +887,55 @@ fn matches(q: &str, label: &str, extra: &str) -> bool { q.is_empty() || label.to_lowercase().contains(q) || extra.to_lowercase().contains(q) } -/// Commands grouped by top-level menu, in catalog order, filtered by the query. +/// A command's row: its label, and its shortcut as the hint. +fn command_row(id: &str, label: String) -> ActionRow { + ActionRow { key: format!("command:{id}"), label, hint: crate::shortcuts::shortcut(id).map(pretty_shortcut).unwrap_or_default(), tile: None } +} + +/// Most rows a search shows (the best matches). +const MAX_HITS: usize = 60; + +/// Commands grouped by top-level menu, filtered by the query. Without one, the menus' items in +/// catalog order; a search covers every command (the engine's and the UI's own, "Other" for those +/// no menu lists), best matches first (labels starting with the query, then the shortest), each +/// group placed by its best row. Labels match in the UI language and in English. The Plugins +/// menu's items come with the plugins ([`plugin_rows`]). pub(crate) fn command_sections(q: &str) -> Vec<(String, Vec)> { - let ql = q.to_lowercase(); - let mut groups: Vec<(String, Vec)> = Vec::new(); - let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); - for m in figcraft_engine::catalog::MENU.iter() { - if m.label == figcraft_engine::catalog::SEP || m.menu.starts_with("Plugins") || !available(m.id) || !seen.insert(m.id) { + let ql = q.trim().to_lowercase(); + let mut menu_of = std::collections::HashMap::new(); + for m in MENU.iter().filter(|m| m.label != SEP) { + menu_of.entry(m.id).or_insert(m); + } + let menu_items = MENU.iter().filter(|m| m.label != SEP).map(|m| (m.id, m.label)); + let engine = figcraft_engine::all_commands().iter().map(|c| (c.id, c.label)); + let candidates: Box> = if ql.is_empty() { Box::new(menu_items) } else { Box::new(menu_items.chain(engine)) }; + let mut seen = std::collections::HashSet::new(); + // By top-level menu; `None`: no menu lists it. + let mut hits: Vec<(Option<&str>, ActionRow)> = Vec::new(); + for (id, english) in candidates { + if !available(id) || !seen.insert(id) { continue; } - let english = m.label; - let label = i18n::menu_item(m.id, english).into_owned(); - if !matches(&ql, &label, &format!("{english} {}", m.id)) { + let menu = menu_of.get(id); + let group = menu.map(|m| m.menu.split('/').next().unwrap_or(m.menu)); + if group == Some("Plugins") { continue; } - let group = m.menu.split('/').next().unwrap_or(&m.menu); - let group = tr(group).to_string(); - let row = ActionRow { - key: format!("command:{}", m.id), - label, - hint: crate::shortcuts::shortcut(m.id).map(pretty_shortcut).unwrap_or_default(), - tile: None, + let label = match menu { + Some(m) => i18n::menu_item(id, m.label), + None => i18n::command(id, english), }; + if matches(&ql, &label, &format!("{english} {id}")) { + hits.push((group, command_row(id, label.into_owned()))); + } + } + if !ql.is_empty() { + hits.sort_by_cached_key(|(_, row)| (!row.label.to_lowercase().starts_with(&ql), row.label.chars().count())); + hits.truncate(MAX_HITS); + } + let mut groups: Vec<(String, Vec)> = Vec::new(); + for (group, row) in hits { + let group = group.map_or_else(|| tr("Other"), tr).to_string(); match groups.iter_mut().find(|(g, _)| *g == group) { Some((_, rows)) => rows.push(row), None => groups.push((group, vec![row])), @@ -945,6 +963,13 @@ fn plugin_rows(app: &FigApp, q: &str) -> Vec { }); } } + // The Plugins menu's own items (run last, manage, import). + for m in MENU.iter().filter(|m| m.label != SEP && m.menu.split('/').next() == Some("Plugins") && available(m.id)) { + let label = i18n::menu_item(m.id, m.label); + if matches(&ql, &label, &format!("{} {}", m.label, m.id)) { + rows.push(command_row(m.id, label.into_owned())); + } + } rows } @@ -974,21 +999,11 @@ fn asset_sections(app: &FigApp, q: &str) -> Vec<(String, Vec)> { .iter() .filter_map(|id| doc.node(*id)) .filter(|n| matches(&ql, &n.name, "")) - .map(|n| ActionRow { - key: format!("component:{}", n.id.0), - label: n.name.clone(), - hint: tr("Component").to_string(), - tile: None, - }) + .map(|n| ActionRow { key: format!("component:{}", n.id.0), label: n.name.clone(), hint: tr("Component").to_string(), tile: None }) .collect(); let styles: Vec = figcraft_engine::library::local_styles(doc) .filter(|s| matches(&ql, &s.name, "")) - .map(|s| ActionRow { - key: format!("style:{}", s.id), - label: s.name.clone(), - hint: tr("Style").to_string(), - tile: None, - }) + .map(|s| ActionRow { key: format!("style:{}", s.id), label: s.name.clone(), hint: tr("Style").to_string(), tile: None }) .collect(); let mut out = Vec::new(); if !components.is_empty() { @@ -1007,15 +1022,11 @@ pub(crate) fn recent_row(app: &FigApp, key: &str, q: &str) -> Option let spec = figcraft_engine::find_command(id); let menu_label = figcraft_engine::catalog::MENU.iter().find(|m| m.id == id); let english = menu_label.map(|m| m.label).or_else(|| spec.map(|s| s.label)).unwrap_or(id); - if !available(id) || !matches(&ql, english, id) { + let label = i18n::command(id, english); + if !available(id) || !matches(&ql, &label, &format!("{english} {id}")) { return None; } - return Some(ActionRow { - key: key.to_string(), - label: i18n::command(id, english).into_owned(), - hint: crate::shortcuts::shortcut(id).map(pretty_shortcut).unwrap_or_default(), - tile: None, - }); + return Some(command_row(id, label.into_owned())); } if let Some(rest) = key.strip_prefix("plugin:") { let (pid, cid) = rest.split_once('/')?; @@ -1025,12 +1036,7 @@ pub(crate) fn recent_row(app: &FigApp, key: &str, q: &str) -> Option if !matches(&ql, &label, pid) { return None; } - return Some(ActionRow { - key: key.to_string(), - label, - hint: tr("Plugin").to_string(), - tile: p.manifest.name.chars().next(), - }); + return Some(ActionRow { key: key.to_string(), label, hint: tr("Plugin").to_string(), tile: p.manifest.name.chars().next() }); } if let Some(pid) = key.strip_prefix("widget:") { let p = app.session.prefs.plugins.iter().find(|p| p.manifest.id == pid && p.manifest.widget)?; diff --git a/crates/ui-egui/src/palette_tests.rs b/crates/ui-egui/src/palette_tests.rs index 06e9fdf5..cfe9c1cd 100644 --- a/crates/ui-egui/src/palette_tests.rs +++ b/crates/ui-egui/src/palette_tests.rs @@ -1,26 +1,22 @@ -//! The Actions palette, driven headless: invoking records recents (newest first, capped), -//! sections group commands by menu, the query filters, gone entries drop out of Recents, a used -//! plugin resolves back to a row, and a press outside the panel closes it. +//! The Actions palette, driven headless: runs from it record recents (newest first, capped), +//! sections group commands by menu, a search covers every command with the best matches first, +//! Up/Down move the highlight and Enter runs it, gone entries drop out of Recents, a used plugin +//! resolves back to a row, and a press outside the panel closes it. -use egui::Pos2; +use egui::{Event, Key, Modifiers, Pos2}; use figcraft_engine::Session; use serde_json::json; use crate::layers_drag_tests::Ui; #[test] -fn invoke_records_recents_newest_first_and_capped() { +fn palette_runs_record_recents_newest_first_and_capped() { let mut u = Ui::new(Session::new()); - for id in [ - "view.zoomIn", - "view.zoomOut", - "edit.copyAsText", - "style.list", - "edit.findReplace", - "edit.copyAsCss", - "view.zoomIn", - ] { - crate::menus::invoke(&mut u.app, id); + // Menus, shortcuts and context menus don't touch the palette's Recents. + crate::menus::invoke(&mut u.app, "view.zoomIn"); + assert!(u.app.session.prefs.recent_actions.is_empty(), "only the palette records recents"); + for id in ["view.zoomIn", "view.zoomOut", "edit.copyAsText", "style.list", "edit.findReplace", "edit.copyAsCss", "view.zoomIn"] { + crate::menus::run_action(&mut u.app, &format!("command:{id}")); } let r = &u.app.session.prefs.recent_actions; assert_eq!(r.len(), figcraft_engine::MAX_RECENT_ACTIONS, "recents are capped: {r:?}"); @@ -81,8 +77,7 @@ fn used_plugin_resolves_to_a_recent_row() { .unwrap(); u.app.session.prefs.plugins.push(plugin); crate::menus::record_recent(&mut u.app, "plugin:dev.local.solar-icons/panel"); - let row = crate::menus::recent_row(&u.app, "plugin:dev.local.solar-icons/panel", "") - .expect("a used plugin stays in Recents"); + let row = crate::menus::recent_row(&u.app, "plugin:dev.local.solar-icons/panel", "").expect("a used plugin stays in Recents"); assert_eq!(row.label, "Solar icons — Panel"); assert!(crate::menus::recent_row(&u.app, "plugin:dev.local.solar-icons/panel", "zzz").is_none(), "the query filters recents"); } @@ -109,3 +104,60 @@ fn outside_press_closes_the_panel() { u.button(Pos2::new(720.0, 20.0), false); assert!(u.app.ui.palette.is_none(), "a single outside press closes the panel"); } + +fn rows(groups: &[(String, Vec)]) -> Vec<&crate::menus::ActionRow> { + groups.iter().flat_map(|(_, rows)| rows).collect() +} + +/// A search reaches commands no menu lists (in an "Other" group), not just menu items. +#[test] +fn search_covers_every_command() { + let menu = figcraft_engine::catalog::MENU; + let spec = figcraft_engine::all_commands().iter().find(|c| !menu.iter().any(|m| m.id == c.id)).expect("a command no menu lists"); + let groups = crate::menus::command_sections(spec.id); + let key = format!("command:{}", spec.id); + let group = groups.iter().find(|(_, rows)| rows.iter().any(|r| r.key == key)).map(|(g, _)| g.as_str()); + assert_eq!(group, Some("Other"), "{} is found: {:?}", spec.id, groups.iter().map(|(g, _)| g).collect::>()); + // Without a query the palette lists the menus' items only. + assert!(rows(&crate::menus::command_sections("")).iter().all(|r| r.key != key)); +} + +/// Labels starting with the query come first, the shortest first; the rest are capped. +#[test] +fn search_ranks_prefix_matches_first() { + let groups = crate::menus::command_sections("zoom"); + let hits = rows(&groups); + let first = hits.first().expect("something matches 'zoom'"); + assert!(first.label.to_lowercase().starts_with("zoom"), "a prefix match leads: {}", first.label); + let shortest = hits.iter().filter(|r| r.label.to_lowercase().starts_with("zoom")).map(|r| r.label.chars().count()).min(); + assert_eq!(Some(first.label.chars().count()), shortest, "the shortest prefix match leads"); + assert!(rows(&crate::menus::command_sections("e")).len() <= 60, "a broad search is capped"); +} + +fn key(u: &mut Ui, k: Key) { + u.frame(vec![ + Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, + Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, + ]); +} + +/// Up and Down move the highlight (it stops at the ends); Enter runs the highlighted row. +#[test] +fn arrows_move_the_highlight_and_enter_runs_it() { + let mut u = Ui::new(Session::new()); + crate::menus::set_palette(&mut u.app.ui, true); + u.frame(vec![]); + u.frame(vec![Event::Text("zoom".into())]); + assert_eq!(u.app.ui.palette.as_deref(), Some("zoom")); + assert_eq!(u.app.ui.palette_hi, 0, "a search starts at the first row"); + key(&mut u, Key::ArrowUp); + assert_eq!(u.app.ui.palette_hi, 0, "it stops at the top"); + key(&mut u, Key::ArrowDown); + key(&mut u, Key::ArrowDown); + key(&mut u, Key::ArrowUp); + assert_eq!(u.app.ui.palette_hi, 1); + let want = rows(&crate::menus::palette_sections(&u.app, "zoom", crate::menus::PaletteTab::All)).get(1).map(|r| r.key.clone()).expect("two rows"); + key(&mut u, Key::Enter); + assert!(u.app.ui.palette.is_none(), "Enter runs a row and closes the palette"); + assert_eq!(u.app.session.prefs.recent_actions.first(), Some(&want), "the highlighted row ran"); +} diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 99df5d6f..481b446c 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -171,12 +171,10 @@ pub fn menu(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> bool { .flatten(); crate::menus::separator(ui); if let Some(plugin) = widget { - crate::menus::record_recent(app, &format!("widget:{plugin}")); app.run("widget.insert", json!({"plugin": plugin})); return true; } let Some((id, command)) = chosen else { return false }; - crate::menus::record_recent(app, &format!("plugin:{id}/{command}")); start(app, &id, &command); true } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 962b7497..8839b3a9 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -85,7 +85,7 @@ pub(crate) fn main_width() -> f32 { fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { let (cell, gap) = (32.0, 4.0); let width = main_width(); - let rect = pill(ui, &mut app.ui.nav, canvas, width, &t); + let rect = pill(ui, &mut app.ui.nav, canvas, width, t); let current = app.session.tool.tool; let mut x = rect.min.x + 8.0; let cy = rect.center().y; @@ -94,7 +94,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { let shown = if in_group { current } else { remembered(ui, g.key, g.tools.first().map(|x| x.0).unwrap_or(ToolId::Move)) }; let icon = g.tools.iter().find(|(tid, _)| *tid == shown).map(|(_, i)| *i).unwrap_or(Icon::Move); let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell)); - let resp = tool_cell(ui, &t, r, ("tool", g.key), icon, in_group, &format!("{} {}", tr(shown.label()), pretty_shortcut(shown.shortcut()))); + let resp = tool_cell(ui, t, r, ("tool", g.key), icon, in_group, &format!("{} {}", tr(shown.label()), pretty_shortcut(shown.shortcut()))); if in_group { a11y::prefer(&mut app.ui.nav, Region::Toolbar, resp.id); } @@ -152,13 +152,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { icons::paint(ui.painter(), r.shrink(7.0), icon, if actions_open { Color32::WHITE } else { t.icon }); if resp.clicked() { if id == "actions" { - if app.ui.palette.is_some() { - app.ui.palette = None; - } else { - app.ui.palette = Some(String::new()); - app.ui.palette_tab = crate::menus::PaletteTab::All; - app.ui.palette_armed = true; - } + crate::menus::toggle_palette(&mut app.ui); } else { app.ui.left_tab = crate::LeftTab::Assets; app.ui.left_collapsed = false; @@ -166,7 +160,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { } x += cell + 4.0; } - divider(ui, &t, x, cy); + divider(ui, t, x, cy); x += 8.0; // Mode switcher: Draw · Design · Prototype · Inspect (code). let modes = [(Icon::Pencil, tr("Draw")), (Icon::Frame, tr("Design")), (Icon::Prototype, tr("Prototype")), (Icon::Code, tr("Inspect"))]; @@ -348,10 +342,15 @@ fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) { } } +/// Where a toolbar pill `width` wide sits: centred 12 px above the canvas's bottom edge. +pub(crate) fn pill_rect(canvas: Rect, width: f32) -> Rect { + Rect::from_center_size(pos2(canvas.center().x, canvas.max.y - 12.0 - 24.0), vec2(width, 48.0)) +} + /// The floating pill (shadow, panel, border) `width` wide at the bottom centre of the canvas: the /// toolbar region keyboard navigation moves through. fn pill(ui: &Ui, nav: &mut Nav, canvas: Rect, width: f32, t: &Tokens) -> Rect { - let rect = Rect::from_center_size(pos2(canvas.center().x, canvas.max.y - 12.0 - 24.0), vec2(width, 48.0)); + let rect = pill_rect(canvas, width); // The pill takes the press and the drag over it: its buttons sense clicks only, and egui gives // a drag the canvas under them would sense to the canvas, so the pen placed an anchor there. // Not focusable: the keyboard walks the buttons, not the pill. From 330f80fcf5422d08e3fda41a30bec971d811c623 Mon Sep 17 00:00:00 2001 From: Andrii Volkov <60794004+volkovskey@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:46:08 +0300 Subject: [PATCH 104/118] fix(format): open large .fig files A real 705 MB .fig file (401 644 nodes, 894 004 layers once instances are expanded) didn't open: "message: too many values". Lifting that cap alone made it import only its first 200 000 layers, and lifting that one took 41 GB of memory. The global caps stay as they are. The .fig import gets budgets from the size of its compressed canvas instead (counted up to the file's own size), not from the message it unpacks to, since chunks inflate up to 512 MB: - the kiwi value budget is one value per 2 bytes of the canvas, at least the old 20 million (kiwi is only read for .fig files); - the layer cap is one layer per 32 bytes of the canvas, at least the REST import's 200 000 and at most 2 000 000. A small or crafted file can't expand its instances into millions of layers, however far it unpacks; the REST JSON import keeps 200 000. Memory: - The mapper no longer passes the document through `json!`, which serialises its arguments: the whole tree was deep-copied twice. - Vector networks are built once per distinct network and handed to the REST importer beside the JSON (`vectorNetworkRef`), not written into it as JSON objects for every instance that shows them. - Decoded kiwi objects are trimmed, and each import stage's input is dropped as soon as the next stage is built. On that file: peak footprint 41 GB -> 11.3 GB, 67 s -> 25 s. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki --- crates/format/src/fig/kiwi.rs | 24 ++- crates/format/src/fig/map.rs | 165 ++++++++++++------ .../map_connector_snapshot_budget_tests.rs | 8 +- crates/format/src/fig/mod.rs | 30 +++- crates/format/src/fig/tail_tests.rs | 4 +- crates/format/src/fig/tests.rs | 61 ++++++- crates/format/src/rest/mod.rs | 66 +++++-- crates/format/src/rest/tests.rs | 15 ++ 8 files changed, 287 insertions(+), 86 deletions(-) diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs index 528dbbf0..46a9daac 100644 --- a/crates/format/src/fig/kiwi.rs +++ b/crates/format/src/fig/kiwi.rs @@ -10,8 +10,12 @@ use std::fmt; /// Deepest struct/message nesting decoded. const MAX_DEPTH: usize = 64; -/// Most values decoded from one message (every scalar, array element and field counts). -const MAX_VALUES: usize = 20_000_000; +/// Most values decoded from one message (every scalar, array element and field counts): at least +/// [`MIN_VALUES`], and one per [`PACKED_BYTES_PER_VALUE`] bytes of the file's compressed canvas, +/// so a large real file decodes while a small one can't expand into a huge tree, however far its +/// chunks inflate. +const MIN_VALUES: usize = 20_000_000; +const PACKED_BYTES_PER_VALUE: usize = 2; /// Most definitions in a schema, and fields in one definition. const MAX_DEFS: usize = 10_000; const MAX_FIELDS: usize = 10_000; @@ -284,13 +288,23 @@ impl Schema { self.defs.iter().position(|d| d.name == name) } - /// Decode a message of definition `root`. + /// Decode a message of definition `root`, within [`MIN_VALUES`] values. pub fn decode_message<'s>(&'s self, root: usize, data: &[u8]) -> Result, KiwiError> { - let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget: MAX_VALUES }; + self.decode_message_within(root, data, MIN_VALUES) + } + + /// [`Self::decode_message`] within `budget` values (see [`value_budget`]). + pub fn decode_message_within<'s>(&'s self, root: usize, data: &[u8], budget: usize) -> Result, KiwiError> { + let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget }; d.def(root, 0) } } +/// Most values decoded from a file whose compressed canvas is `packed` bytes. +pub(crate) fn value_budget(packed: usize) -> usize { + MIN_VALUES.max(packed / PACKED_BYTES_PER_VALUE) +} + struct Decoder<'s, 'b> { schema: &'s Schema, r: Reader<'b>, @@ -327,6 +341,8 @@ impl<'s> Decoder<'s, '_> { loop { let id = self.r.var_uint()?; if id == 0 { + // Grown by doubling: trimmed, a large file's tree takes a good part less. + out.shrink_to_fit(); return Ok(Kv::Object(out)); } let f = d.fields.iter().find(|f| f.value == id).ok_or_else(|| KiwiError(format!("message: {} has no field {id}", d.name)))?; diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 7a3dab5c..a8af2ab9 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -6,9 +6,10 @@ //! are expanded here from the main component (overrides and derived layout matched by id path, //! component property values applied) so the import looks as saved. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::rc::Rc; +use figcraft_doc::VectorNetwork; use figcraft_doc::geom::{Affine, Point}; use figcraft_doc::proto::{MAX_ACTION_DEPTH, MAX_EXPR_DEPTH}; use serde_json::{Map, Value, json}; @@ -18,8 +19,7 @@ use super::network; type Obj = Map; -/// Most layers produced (instance expansion included) and deepest nesting followed. -const MAX_NODES: usize = crate::rest::MAX_NODES; +/// Deepest nesting followed. const MAX_DEPTH: usize = 256; /// Most path commands read from one geometry blob. const MAX_COMMANDS: usize = 200_000; @@ -30,11 +30,41 @@ const NO_STYLE: &str = "4294967295:4294967295"; /// Longest asset `key` or `version` kept for resolving references given as assets. const MAX_ASSET_PART: usize = 256; -/// The REST API file JSON for message `msg`, with the warnings about what was skipped. +/// The REST API file JSON for message `msg`, with the warnings about what was skipped and the +/// vector networks its layers name by `vectorNetworkRef` (for [`crate::rest::import_fig_parts`]). +/// [`to_rest_parts`] within the REST import's layer cap, with each vector's network written back +/// into its JSON as `vectorNetwork`: what the tests read. +#[cfg(test)] pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec) { - let mut m = Mapper::new(msg); + fn inline(v: &mut Value, networks: &[Option]) { + match v { + Value::Object(o) => { + let net = o.remove("vectorNetworkRef").and_then(|r| networks.get(usize::try_from(r.as_u64()?).ok()?).cloned().flatten()); + if let Some(net) = net.and_then(|n| serde_json::to_value(n).ok()) { + o.insert("vectorNetwork".into(), net); + } + o.values_mut().for_each(|c| inline(c, networks)); + } + Value::Array(a) => a.iter_mut().for_each(|c| inline(c, networks)), + _ => {} + } + } + let (mut json, warnings, networks) = to_rest_parts(msg, name, MAX_NODES); + inline(&mut json, &networks); + (json, warnings) +} + +/// The REST import's layer cap, for the tests. +#[cfg(test)] +pub(super) const MAX_NODES: usize = crate::rest::MAX_NODES; + +pub(super) fn to_rest_parts(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec, Vec>) { + let mut m = Mapper::new(msg, max_nodes); let doc = m.document(); - let mut out = json!({"name": name, "document": doc}); + // Large values are moved in, never passed through `json!`, which serialises its arguments: + // a deep copy of the whole tree, briefly holding it twice. + let mut out = json!({"name": name}); + out["document"] = doc; if !m.library_contexts.is_empty() { out[crate::rest::FIG_LIBRARY_CONTEXTS_FIELD] = Value::Array(std::mem::take(&mut m.library_contexts)); } @@ -54,7 +84,7 @@ pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec) { if !styles.is_empty() { out["styleDefinitions"] = Value::Array(styles); } - (out, m.warnings) + (out, m.warnings, m.networks) } #[cfg(test)] @@ -374,20 +404,27 @@ struct Mapper<'a> { sets: Obj, /// Layers and keys of each main, built when an instance of it first needs them. override_scopes: BTreeMap>, - /// Entries held by `override_scopes` (bounded by [`MAX_NODES`] together). + /// Entries held by `override_scopes` (bounded by `max_nodes` together). override_scope_entries: usize, /// Component-bearing branches of the file's internal canvases (cached library definitions). library_contexts: Vec, /// Layers visited while scanning internal canvases, across all of them. library_scan_count: usize, - /// Most layers produced or scanned; the one place the cap comes from. + /// Most layers produced (instance expansion included) or scanned; the one place the cap comes + /// from (see [`super::layer_budget`]). max_nodes: usize, warnings: Vec, count: usize, + /// Vector networks, each built once: the layers that show one (every instance of a main + /// holding it) name it by `vectorNetworkRef`. Kept out of the JSON, where a large file's + /// networks took gigabytes. + networks: Vec>, + /// Index in `networks` by what a network is made from: its blob, its scale and its style table. + network_ids: HashMap<(usize, u64, u64, usize), usize>, } impl<'a> Mapper<'a> { - fn new(msg: &'a Kv<'a>) -> Self { + fn new(msg: &'a Kv<'a>, max_nodes: usize) -> Self { let mut nodes = BTreeMap::new(); let mut kids: BTreeMap> = BTreeMap::new(); let mut prop_defs = BTreeMap::new(); @@ -447,9 +484,11 @@ impl<'a> Mapper<'a> { override_scope_entries: 0, library_contexts: Vec::new(), library_scan_count: 0, - max_nodes: MAX_NODES, + max_nodes, warnings: Vec::new(), count: 0, + networks: Vec::new(), + network_ids: HashMap::new(), } } @@ -498,7 +537,7 @@ impl<'a> Mapper<'a> { let mut pending: Vec<(String, usize)> = self.children(main).into_iter().map(|id| (id, 1)).collect(); let mut complete = true; while let Some((id, depth)) = pending.pop() { - if depth > MAX_DEPTH || visited.len() >= MAX_NODES { + if depth > MAX_DEPTH || visited.len() >= self.max_nodes { complete = false; break; } @@ -523,7 +562,7 @@ impl<'a> Mapper<'a> { } let scope = Rc::new(OverrideScope { current, aliases }); let cost = scope.current.len() + scope.aliases.len(); - if cost <= MAX_NODES.saturating_sub(self.override_scope_entries) { + if cost <= self.max_nodes.saturating_sub(self.override_scope_entries) { self.override_scope_entries += cost; self.override_scopes.insert(main.to_string(), Rc::clone(&scope)); } @@ -563,7 +602,9 @@ impl<'a> Mapper<'a> { if out.is_empty() { self.warn("the file has no pages"); } - json!({"id": doc_id.unwrap_or_else(|| "0:0".into()), "type": "DOCUMENT", "children": out}) + let mut doc = json!({"id": doc_id.unwrap_or_else(|| "0:0".into()), "type": "DOCUMENT"}); + doc["children"] = Value::Array(out); + doc } /// The ids of the components the visible pages use, directly or through the components they @@ -777,8 +818,9 @@ impl<'a> Mapper<'a> { self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); return None; } - if self.count >= MAX_NODES { - self.warn(format!("the file has more than {MAX_NODES} layers; the rest were skipped")); + if self.count >= self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the file has more than {max} layers; the rest were skipped")); return None; } let base = *self.nodes.get(id)?; @@ -813,7 +855,7 @@ impl<'a> Mapper<'a> { let connector_snapshot = own == "CONNECTOR" && self.connector_snapshot_supported(&view); // Only a connector with a saved label needs the frame wrapper; an unlabelled one stays a single vector. let connector_terminal = - connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= MAX_NODES); + connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.max_nodes); // A supported connector snapshot has its own note below instead of the generic stand-in one. let stand = stand_in(own).filter(|_| !connector_snapshot); let ty = match own { @@ -947,7 +989,7 @@ impl<'a> Mapper<'a> { fn shape_with_text(&mut self, o: &mut Obj, v: &View<'a>, own: &str, rest_id: &str) -> Option { let shape = v.str("shapeWithTextType").unwrap_or("SQUARE"); let outlined = !v.list("fillGeometry").is_empty() || !v.list("strokeGeometry").is_empty(); - if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= MAX_NODES { + if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= self.max_nodes { return None; } let mut s = Obj::new(); @@ -979,7 +1021,7 @@ impl<'a> Mapper<'a> { /// layer filling it, inset a little; centred unless it's a note. fn inner_text(&mut self, o: &Obj, v: &View<'a>, rest_id: &str, note: bool) -> Option { let chars = own_text(v)?; - if self.count >= MAX_NODES { + if self.count >= self.max_nodes { return None; } self.count += 1; @@ -1013,7 +1055,7 @@ impl<'a> Mapper<'a> { if connector_has_saved_label(v) { self.warn("connector labels were skipped"); } - if ["vectorNetwork", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) { + if ["vectorNetwork", "vectorNetworkRef", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) { return; } let parent = v.get("parentIndex").and_then(|p| p.get("guid")).and_then(guid); @@ -1114,7 +1156,7 @@ impl<'a> Mapper<'a> { out.insert("opacity".into(), json!(root_opacity * line_opacity)); } if connector_has_saved_label(v) { - let reason = if self.count >= MAX_NODES { "node budget" } else { "depth limit" }; + let reason = if self.count >= self.max_nodes { "node budget" } else { "depth limit" }; self.warn(format!("connector label was skipped: {reason} prevents nested snapshot children")); } } @@ -1144,8 +1186,9 @@ impl<'a> Mapper<'a> { /// Snapshot the straight, manual connector contract carried by its generated children. fn connector_snapshot_children(&mut self, v: &View<'a>, rest_id: &str) -> Vec { - if self.count >= MAX_NODES { - self.warn(format!("the file has more than {MAX_NODES} layers; connector snapshot children were skipped")); + if self.count >= self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the file has more than {max} layers; connector snapshot children were skipped")); return Vec::new(); } let Some(vector) = self.connector_snapshot_vector(v, rest_id, Affine::IDENTITY, true) else { @@ -1207,8 +1250,9 @@ impl<'a> Mapper<'a> { }; let background_view = View::of(background).with([background_style]); if !background_view.list("fillPaints").is_empty() { - if self.count >= MAX_NODES { - self.warn(format!("the file has more than {MAX_NODES} layers; connector label background was skipped")); + if self.count >= self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the file has more than {max} layers; connector label background was skipped")); } else { let mut rect = Obj::new(); rect.insert("id".into(), json!(format!("{rest_id};label-background"))); @@ -1235,8 +1279,9 @@ impl<'a> Mapper<'a> { transform: Affine, view: &View<'a>, ) { - if self.count >= MAX_NODES { - self.warn(format!("the file has more than {MAX_NODES} layers; connector label was skipped")); + if self.count >= self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the file has more than {max} layers; connector label was skipped")); return; } let mut text = Obj::new(); @@ -1648,7 +1693,8 @@ impl<'a> Mapper<'a> { /// False when there's none or it's damaged (the outlines stand in). fn network(&mut self, o: &mut Obj, v: &View<'a>) -> bool { let Some(vd) = v.get("vectorData") else { return false }; - let Some(blob) = num(vd, "vectorNetworkBlob").and_then(|i| self.blobs.get(i as usize)).copied() else { return false }; + let Some(blob_id) = num(vd, "vectorNetworkBlob").map(|i| i as usize) else { return false }; + let Some(blob) = self.blobs.get(blob_id).copied() else { return false }; let Some(net) = network::decode(blob) else { self.warn("damaged vector networks import as outlines"); return false; @@ -1686,28 +1732,6 @@ impl<'a> Mapper<'a> { size.zip(norm).filter(|(s, n)| s.is_finite() && *n > 1e-9).map_or(1.0, |(s, n)| s / n) }; let (sx, sy) = (scale("x"), scale("y")); - let vertices: Vec = net - .vertices - .iter() - .map(|p| { - let mut out = json!({"x": p.x * sx, "y": p.y * sy}); - let st = style(p.style); - if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) { - out["radius"] = json!(r); - } - match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) { - Some("ANGLE") => out["mirror"] = json!("angle"), - Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"), - Some("NONE") => out["mirror"] = json!("none"), - _ => {} - } - out - }) - .collect(); - let segments: Vec = - net.segments.iter().map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]})).collect(); - let regions: Vec = - net.regions.iter().map(|r| json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }})).collect(); if net.regions.iter().any(|r| style(r.style).is_some_and(|s| s.get("fillPaints").is_some())) { self.warn("filled areas of a vector with their own fill use the layer's fill"); } @@ -1733,7 +1757,46 @@ impl<'a> Mapper<'a> { o.insert("strokeCapStart".into(), json!(cap_name(first.unwrap_or(node)))); o.insert("strokeCapEnd".into(), json!(cap_name(last.unwrap_or(node)))); } - o.insert("vectorNetwork".into(), json!({"vertices": vertices, "segments": segments, "regions": regions})); + // Every instance of a main shows the same network: it is written once and shared. + let key = (blob_id, sx.to_bits(), sy.to_bits(), std::ptr::from_ref(vd) as usize); + let id = match self.network_ids.get(&key) { + Some(id) => *id, + None => { + let vertices: Vec = net + .vertices + .iter() + .map(|p| { + let mut out = json!({"x": p.x * sx, "y": p.y * sy}); + let st = style(p.style); + if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) { + out["radius"] = json!(r); + } + match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) { + Some("ANGLE") => out["mirror"] = json!("angle"), + Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"), + Some("NONE") => out["mirror"] = json!("none"), + _ => {} + } + out + }) + .collect(); + let segments: Vec = net + .segments + .iter() + .map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]})) + .collect(); + let regions: Vec = + net.regions.iter().map(|r| json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }})).collect(); + let mut entry = json!({}); + entry["vertices"] = Value::Array(vertices); + entry["segments"] = Value::Array(segments); + entry["regions"] = Value::Array(regions); + self.networks.push(crate::rest::network(&entry)); + self.network_ids.insert(key, self.networks.len() - 1); + self.networks.len() - 1 + } + }; + o.insert("vectorNetworkRef".into(), json!(id)); true } diff --git a/crates/format/src/fig/map_connector_snapshot_budget_tests.rs b/crates/format/src/fig/map_connector_snapshot_budget_tests.rs index a14e671c..4d8ae9be 100644 --- a/crates/format/src/fig/map_connector_snapshot_budget_tests.rs +++ b/crates/format/src/fig/map_connector_snapshot_budget_tests.rs @@ -106,7 +106,7 @@ fn connector_snapshot_respects_last_and_zero_child_slots() { let message = message(); let node = message.get("nodeChanges").unwrap().list().first().unwrap(); let view = View::of(node); - let mut mapper = Mapper::new(&message); + let mut mapper = Mapper::new(&message, MAX_NODES); mapper.count = MAX_NODES - 1; let children = mapper.connector_snapshot_children(&view, "1:123"); assert_eq!(children.len(), 1, "the final available slot preserves the saved vector"); @@ -116,7 +116,7 @@ fn connector_snapshot_respects_last_and_zero_child_slots() { assert_eq!(mapper.count, MAX_NODES); assert!(mapper.warnings.iter().any(|warning| warning == &format!("the file has more than {MAX_NODES} layers; connector label was skipped"))); - let mut mapper = Mapper::new(&message); + let mut mapper = Mapper::new(&message, MAX_NODES); mapper.count = MAX_NODES; let children = mapper.connector_snapshot_children(&view, "1:123"); assert!(children.is_empty()); @@ -127,14 +127,14 @@ fn connector_snapshot_respects_last_and_zero_child_slots() { #[test] fn connector_snapshot_flattens_vector_at_depth_and_node_limits() { let message = message(); - let mut mapper = Mapper::new(&message); + let mut mapper = Mapper::new(&message, MAX_NODES); let depth_limited = mapper.layer("1:123", None, Affine::IDENTITY, None, None, MAX_DEPTH).expect("terminal connector"); assert_eq!(depth_limited["type"], "VECTOR"); assert!(!depth_limited["fillGeometry"].as_array().unwrap().is_empty()); assert!(depth_limited.get("children").is_none()); assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: depth limit prevents nested snapshot children")); - let mut mapper = Mapper::new(&message); + let mut mapper = Mapper::new(&message, MAX_NODES); mapper.count = MAX_NODES - 1; let budget_limited = mapper.layer("1:123", None, Affine::IDENTITY, None, None, 0).expect("last allowed connector"); assert_eq!(budget_limited["type"], "VECTOR"); diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 0d341307..08a3409e 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -54,6 +54,18 @@ const MAX_CHUNK: u64 = 512 * 1024 * 1024; const MAX_IMAGE: u64 = 256 * 1024 * 1024; /// Most chunks read from `canvas.fig`. const MAX_CHUNKS: usize = 16; +/// Most layers imported from a `.fig` file: one per [`PACKED_BYTES_PER_LAYER`] bytes of its +/// compressed canvas, at least the REST import's cap and at most [`MAX_FIG_NODES`]. Instances +/// expand into a layer per layer of their component, and chunks inflate up to [`MAX_CHUNK`], so the +/// budget follows what the file takes, not what it unpacks to: a small file can't claim a large +/// count, however it's built; a large one gets room. +const PACKED_BYTES_PER_LAYER: usize = 32; +const MAX_FIG_NODES: usize = 2_000_000; + +/// Most layers imported from a `.fig` file whose compressed canvas is `packed` bytes. +pub(crate) fn layer_budget(packed: usize) -> usize { + (packed / PACKED_BYTES_PER_LAYER).clamp(crate::rest::MAX_NODES, MAX_FIG_NODES) +} /// Whether `bytes` look like a `.fig` file: a zip holding `canvas.fig`, or a bare canvas. pub fn is_fig(bytes: &[u8]) -> bool { @@ -72,14 +84,22 @@ pub fn try_import(bytes: &[u8], name: &str) -> Option Result<(Document, Vec), FormatError> { - let archive = Archive::open(bytes)?; - let (schema, message) = decode_canvas(&archive.canvas)?; + let mut archive = Archive::open(bytes)?; + // The budgets follow the compressed canvas, which the file itself bounds (its zip member may + // inflate too). + let packed = archive.canvas.len().min(bytes.len()); + // Each stage's input is dropped as soon as the next one is built: in a large file every one + // of them takes gigabytes, and holding them all at once runs out of memory. + let (schema, message) = decode_canvas(&std::mem::take(&mut archive.canvas))?; let root = schema.find("Message").ok_or_else(|| corrupt("the schema has no Message definition"))?; - let msg = schema.decode_message(root, &message).map_err(|e| corrupt(e.to_string()))?; + let msg = schema.decode_message_within(root, &message, kiwi::value_budget(packed)).map_err(|e| corrupt(e.to_string()))?; + let max_nodes = layer_budget(packed); + drop(message); let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name); - let (json, mut warnings) = map::to_rest(&msg, file_name); + let (json, mut warnings, networks) = map::to_rest_parts(&msg, file_name, max_nodes); + drop(msg); let images = |r: &str| archive.image(r); - let (doc, more) = crate::rest::import_fig(&json, &images)?; + let (doc, more) = crate::rest::import_fig_parts(&json, &images, networks, max_nodes)?; warnings.extend(more.into_iter().filter_map(map::adapt_warning)); Ok((doc, warnings)) } diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index a15334a5..43e997a5 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -446,8 +446,8 @@ fn mangled_tail_files_never_panic() { m[i] = r.next() as u8; } if let Ok(kv) = sc.decode_message(root, &m) { - let (json, _) = map::to_rest(&kv, "x"); - let _ = crate::rest::import(&json, &crate::rest::no_images); + let (json, _, networks) = map::to_rest_parts(&kv, "x", crate::rest::MAX_NODES); + let _ = crate::rest::import_fig_parts(&json, &crate::rest::no_images, networks, crate::rest::MAX_NODES); } } } diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 24baef30..5f20f5ac 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -757,7 +757,7 @@ fn mangled_kiwi_never_panics() { } for cut in (0..msg.len()).step_by(3) { if let Ok(kv) = sc.decode_message(root, &msg[..cut]) { - let _ = map::to_rest(&kv, "x"); + let _ = map::to_rest_parts(&kv, "x", crate::rest::MAX_NODES); } } for _ in 0..2000 { @@ -767,8 +767,8 @@ fn mangled_kiwi_never_panics() { m[i] = r.next() as u8; } if let Ok(kv) = sc.decode_message(root, &m) { - let (json, _) = map::to_rest(&kv, "x"); - let _ = crate::rest::import(&json, &crate::rest::no_images); + let (json, _, networks) = map::to_rest_parts(&kv, "x", crate::rest::MAX_NODES); + let _ = crate::rest::import_fig_parts(&json, &crate::rest::no_images, networks, crate::rest::MAX_NODES); } let mut sbytes = schema_bytes.clone(); let i = (r.next() as usize) % sbytes.len(); @@ -790,6 +790,59 @@ fn mangled_kiwi_never_panics() { } } +#[test] +fn large_files_get_a_larger_value_budget() { + // Small files keep the fixed floor: a few bytes can't expand into a huge tree. + assert_eq!(kiwi::value_budget(0), 20_000_000); + assert_eq!(kiwi::value_budget(1 << 20), 20_000_000); + // A real 400 000-layer file: an 83.5 MB canvas whose message unpacks to 334 MB and holds + // 30.5 million values was refused as "too many values". + assert!(kiwi::value_budget(83_491_232) > 30_514_899); + // Still bounded by the largest canvas read. + assert!(kiwi::value_budget(MAX_CANVAS as usize) <= 128 << 20); +} + +#[test] +fn the_layer_cap_follows_the_file_size() { + // A small (or broken) file keeps the REST import's cap: instances expand into a layer per + // layer of their component, and a few bytes mustn't claim millions of layers. + assert_eq!(layer_budget(0), crate::rest::MAX_NODES); + assert_eq!(layer_budget(1 << 20), crate::rest::MAX_NODES); + // A real 894 004-layer file (an 83.5 MB canvas) stopped at 200 000 and came in with 27 of + // its 30 pages empty. + assert!(layer_budget(83_491_232) > 894_004); + // No file gets more than the ceiling. + assert_eq!(layer_budget(MAX_CANVAS as usize), MAX_FIG_NODES); +} + +#[test] +fn a_small_file_that_unpacks_large_gets_no_larger_budgets() { + // 40 MB of zeros in a blob pack into a few KB. Keyed to the unpacked message, they lifted the + // layer cap above the 231 231 layers its instances expand into; keyed to the file, the cap + // stays at the REST import's. + let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])]; + nodes.push(node(5, Some((1, "!")), "SYMBOL", "Main", vec![("size", xy(10.0, 10.0))])); + nodes.extend((10..1010).map(|i| node(i, Some((5, "!")), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))]))); + nodes.extend((2000..2230).map(|i| node(i, Some((1, "!")), "INSTANCE", "I", vec![("symbolData", o(vec![("symbolID", g(5))]))]))); + let blob = o(vec![("bytes", Kv::Bytes(vec![0; 40 << 20]))]); + let file = canvas(&schema(), &o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(vec![blob]))])); + assert!(file.len() < 1 << 20, "{} bytes", file.len()); + let (doc, warnings) = import(&file, "x").unwrap(); + assert!(warnings.iter().any(|w| w.contains("more than 200000 layers")), "{warnings:?}"); + assert!(doc.node_count() <= crate::rest::MAX_NODES + 10); +} + +#[test] +fn the_mapper_stops_at_the_layer_cap_it_is_given() { + let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])]; + nodes.extend((2..12).map(|i| node(i, Some((1, "!")), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))]))); + let msg = o(vec![("nodeChanges", arr(nodes))]); + let (_, warnings, _) = map::to_rest_parts(&msg, "x", 5); + assert!(warnings.iter().any(|w| w.contains("more than 5 layers")), "{warnings:?}"); + let (_, warnings, _) = map::to_rest_parts(&msg, "x", crate::rest::MAX_NODES); + assert!(!warnings.iter().any(|w| w.contains("more than")), "{warnings:?}"); +} + #[test] fn hostile_input_is_refused() { // A message that contains itself, nested past the depth cap. @@ -1088,7 +1141,7 @@ fn dump_samples() { std::fs::write(format!("{out}/{stem}.schema.txt"), txt).unwrap(); let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); std::fs::write(format!("{out}/{stem}.json"), serde_json::to_string_pretty(&debug_json(&msg)).unwrap()).unwrap(); - let (rest, _) = map::to_rest(&msg, &stem); + let (rest, ..) = map::to_rest_parts(&msg, &stem, super::layer_budget(message.len())); std::fs::write(format!("{out}/{stem}.rest.json"), serde_json::to_string_pretty(&rest).unwrap()).unwrap(); } } diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index e460c8b8..0fa53a7c 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -53,7 +53,7 @@ use crate::FormatError; use props::{MAX_COORD, MAX_SIZE, clip_str, flag, list, num, text}; pub use variables::export as export_variables; -/// Most layers imported from one file. +/// Most layers imported from one file. The `.fig` import sets its own, from the file's size. pub const MAX_NODES: usize = 200_000; /// Deepest layer nesting followed. const MAX_DEPTH: usize = 256; @@ -146,22 +146,43 @@ pub fn import(v: &Value, images: ImageResolver) -> Result<(Document, Vec /// in the file JSON (a `meta` with `variableCollections`, or `variableCollections` and /// `variables` at the top level) are read too; the first definition of an id wins. pub fn import_with(v: &Value, variables: Option<&Value>, images: ImageResolver) -> Result<(Document, Vec), FormatError> { - import_impl(v, variables, images, false) + import_impl(v, variables, images, false, Vec::new(), MAX_NODES) } -/// Internal .fig JSON path: GUID provenance is not part of the public REST import contract. +/// Internal .fig JSON path: GUID provenance is not part of the public REST import contract. Its +/// vectors name their networks by `vectorNetworkRef`, an index in `networks` (built once each, and +/// far smaller than as JSON); `max_nodes` is the layer cap the `.fig` import sets in place of +/// [`MAX_NODES`]. +pub(crate) fn import_fig_parts( + v: &Value, + images: ImageResolver, + networks: Vec>, + max_nodes: usize, +) -> Result<(Document, Vec), FormatError> { + import_impl(v, None, images, true, networks, max_nodes) +} + +/// [`import_fig_parts`] of JSON that carries its vectors' networks, within [`MAX_NODES`]. +#[cfg(test)] pub(crate) fn import_fig(v: &Value, images: ImageResolver) -> Result<(Document, Vec), FormatError> { - import_impl(v, None, images, true) + import_fig_parts(v, images, Vec::new(), MAX_NODES) } -fn import_impl(v: &Value, variables: Option<&Value>, images: ImageResolver, fig_sources: bool) -> Result<(Document, Vec), FormatError> { +fn import_impl( + v: &Value, + variables: Option<&Value>, + images: ImageResolver, + fig_sources: bool, + networks: Vec>, + max_nodes: usize, +) -> Result<(Document, Vec), FormatError> { let entries: Vec<&Value> = if is_document(v) { vec![v] } else { nodes_entries(v).collect() }; if entries.is_empty() { return Err(FormatError::Corrupt("not a REST API file JSON (no `document`)".into())); } let name = text(v, "name").filter(|n| !n.trim().is_empty()).map_or("Imported file", clip_str); let mut cx = Ctx::new(Document::new(name), images); - cx.fig_sources = fig_sources.then(|| fig_sources::Sources::new(MAX_NODES)); + cx.fig_sources = fig_sources.then(|| fig_sources::Sources::new(max_nodes)); let blank = cx.doc.first_page(); let mut loose: Option = None; for e in &entries { @@ -175,6 +196,8 @@ fn import_impl(v: &Value, variables: Option<&Value>, images: ImageResolver, fig_ cx.warn("the variables JSON has no `variableCollections` (expected the local variables response) and was ignored"); } cx.style_definitions(v); + cx.networks = networks; + cx.max_nodes = max_nodes; for e in entries { let Some(root) = e.get("document") else { continue }; match text(root, "type") { @@ -356,6 +379,10 @@ pub(crate) struct Ctx<'a> { /// Imported variables by id, and the mode ids of imported collections (default first). var_kinds: BTreeMap, var_modes: BTreeMap>, + /// Vector networks built by the `.fig` import, named by layers' `vectorNetworkRef`. + networks: Vec>, + /// Most layers imported: [`MAX_NODES`], or the `.fig` import's own cap. + max_nodes: usize, } impl<'a> Ctx<'a> { @@ -380,6 +407,8 @@ impl<'a> Ctx<'a> { overlay_meta: BTreeMap::new(), var_kinds: BTreeMap::new(), var_modes: BTreeMap::new(), + networks: Vec::new(), + max_nodes: MAX_NODES, } } @@ -435,8 +464,9 @@ impl<'a> Ctx<'a> { self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); return; } - if self.count >= MAX_NODES { - self.warn(format!("the file has more than {MAX_NODES} layers; the rest were skipped")); + if self.count >= self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the file has more than {max} layers; the rest were skipped")); return; } let Some(ty) = text(j, "type") else { @@ -642,14 +672,18 @@ impl<'a> Ctx<'a> { } } - /// A vector's network: the editable one when the JSON carries it (`vectorNetwork`, our - /// extension, written by the `.fig` import), else from its geometry. A stroke-only vector - /// uses the stroke outline, painted with the stroke paints (exact look, editable as a shape): - /// returns whether it does, and the caller moves the paints with [`outline_stroke`] once the - /// styles and variables of the stroke are read. + /// A vector's network: the editable one when the JSON carries it (`vectorNetwork`, or + /// `vectorNetworkRef` naming one built by the `.fig` import: our extensions), else from its + /// geometry. A stroke-only vector uses the stroke outline, painted with the stroke paints + /// (exact look, editable as a shape): returns whether it does, and the caller moves the paints + /// with [`outline_stroke`] once the styles and variables of the stroke are read. fn vector(&mut self, n: &mut Node, j: &Value) -> bool { - if let Some(net) = j.get("vectorNetwork") { - match network(net) { + let net = match j.get("vectorNetworkRef") { + Some(r) => Some(r.as_u64().and_then(|i| self.networks.get(usize::try_from(i).ok()?).cloned().flatten())), + None => j.get("vectorNetwork").map(network), + }; + if let Some(net) = net { + match net { Some(network) => { n.kind = NodeKind::Vector { network }; return false; @@ -1173,7 +1207,7 @@ fn local(parent: Affine, world: Affine) -> Affine { /// Finite path data within the coordinate cap. /// A vector network in our own JSON shape, when it's within bounds and refers only to what it has. -fn network(v: &Value) -> Option { +pub(crate) fn network(v: &Value) -> Option { use serde::Deserialize; let mut net = VectorNetwork::deserialize(v).ok()?; let entries: usize = net.regions.iter().flat_map(|r| &r.loops).map(Vec::len).sum(); diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index 7db22db1..cdf27905 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -729,3 +729,18 @@ fn layer_count_is_capped() { assert_eq!(doc.node_count(), MAX_NODES + 1); assert!(warnings.iter().any(|w| w.contains("more than")), "{warnings:?}"); } + +#[test] +fn the_fig_import_sets_its_own_layer_cap() { + // The `.fig` import passes a cap from the file's size: above `MAX_NODES` every layer comes in. + let kids: Vec = (0..MAX_NODES + 5).map(|i| json!({"id": i.to_string(), "type": "RECTANGLE", "name": "R"})).collect(); + let json = file(json!([page("Page", Value::Array(kids))])); + let (doc, warnings) = import_fig_parts(&json, &no_images, Vec::new(), MAX_NODES + 10).unwrap(); + assert_eq!(doc.node_count(), MAX_NODES + 6); + assert!(!warnings.iter().any(|w| w.contains("more than")), "{warnings:?}"); + // Below it, the import stops at the cap it was given. + let few: Vec = (0..10).map(|i| json!({"id": i.to_string(), "type": "RECTANGLE", "name": "R"})).collect(); + let (doc, warnings) = import_fig_parts(&file(json!([page("Page", Value::Array(few))])), &no_images, Vec::new(), 4).unwrap(); + assert_eq!(doc.node_count(), 5); + assert!(warnings.iter().any(|w| w.contains("more than 4 layers")), "{warnings:?}"); +} From 62ade9b9ad86f0014627f569b95995162907f66f Mon Sep 17 00:00:00 2001 From: Andrii Volkov <60794004+volkovskey@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:46:08 +0300 Subject: [PATCH 105/118] fix(format): reopen large documents once saved `document.json` was read under the 512 MB cap for any archive member, so a large imported document saved but didn't open again: 900 000 layers save as a 1.48 GB document.json, packed into 244 MB. It may now unpack to 16 times its packed size (counted up to the archive's own size), within 512 MB and 4 GB; images and other members keep 512 MB. A small archive still can't unpack into gigabytes. That saved file reopens in 13 s with a 5.9 GB peak. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki --- crates/format/src/large_tests.rs | 15 +++++++++++++++ crates/format/src/lib.rs | 14 +++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/format/src/large_tests.rs b/crates/format/src/large_tests.rs index 3ff352ac..2dc9eb4c 100644 --- a/crates/format/src/large_tests.rs +++ b/crates/format/src/large_tests.rs @@ -202,3 +202,18 @@ fn the_header_is_read_on_its_own() { let (_, _, index) = parse_json(&d.replacen("\"imageIndex\":[]", "\"imageIndex\":[{\"hash\":\"ab\"},{\"nope\":1}]", 1)).unwrap(); assert_eq!(index, Some(["ab".to_string()].into_iter().collect())); } + +#[test] +fn document_json_may_unpack_in_proportion_to_its_packed_size() { + const MB: u64 = 1024 * 1024; + // A small archive keeps the member cap: it can't unpack into gigabytes. + assert_eq!(document_limit(0, 0), MAX_MEMBER); + assert_eq!(document_limit(MB, 2 * MB), MAX_MEMBER); + // A real 900 000-layer document saved by us packs 1 477 139 810 bytes into 244 362 923, and + // didn't open again under the 512 MB cap. + assert!(document_limit(244_362_923, 260 * MB) > 1_477_139_810); + // The packed size an archive claims counts only up to the archive's own size. + assert_eq!(document_limit(u64::MAX, MB), MAX_MEMBER); + // Never more than the ceiling. + assert_eq!(document_limit(u64::MAX, u64::MAX), MAX_DOCUMENT); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index f88416e2..dd96b932 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -39,6 +39,11 @@ pub const FORMAT: &str = "figcraft"; pub const VERSION: u32 = 1; /// Largest archive member we read (guards against zip bombs). const MAX_MEMBER: u64 = 512 * 1024 * 1024; +/// `document.json` may unpack to [`DOCUMENT_RATIO`] times what it takes in the archive, within +/// [`MAX_MEMBER`] and [`MAX_DOCUMENT`]: a large document saved by us (a 900 000-layer file packs +/// 1.48 GB into 244 MB) opens again, while a small archive still can't unpack into gigabytes. +const DOCUMENT_RATIO: u64 = 16; +const MAX_DOCUMENT: u64 = 4 * 1024 * 1024 * 1024; /// Most versions a file keeps: saving more is refused, extra entries in a file are skipped. pub const MAX_VERSIONS: usize = 100; /// Longest version name and description kept (chars). @@ -455,6 +460,11 @@ pub(crate) fn read_member(z: &mut zip::ZipArchive, name: &str Ok(data) } +/// Largest `document.json` read from an archive of `len` bytes in which it takes `packed`. +pub(crate) fn document_limit(packed: u64, len: u64) -> u64 { + packed.min(len).saturating_mul(DOCUMENT_RATIO).clamp(MAX_MEMBER, MAX_DOCUMENT) +} + /// How many images, and how many bytes of them together, one file may give. #[derive(Clone, Copy, Debug)] pub(crate) struct ImageBudget { @@ -476,7 +486,9 @@ pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result Date: Sat, 17 Oct 2026 09:27:42 +0300 Subject: [PATCH 106/118] fix(format): one kiwi value per compressed canvas byte One value per two bytes of the compressed canvas was too tight for a public design system: a 39.5 MB canvas holding 29.7 million values got a budget of 19.7 million (reported in #701). Real files hold 0.37 to 0.75 values per compressed byte; the budget is now one per byte, at least the old 20 million and at most 128 million. A small file still gets main's 20 million, however far it unpacks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki --- crates/format/src/fig/kiwi.rs | 10 +++++----- crates/format/src/fig/tests.rs | 7 +++++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs index 46a9daac..6496062b 100644 --- a/crates/format/src/fig/kiwi.rs +++ b/crates/format/src/fig/kiwi.rs @@ -11,11 +11,11 @@ use std::fmt; /// Deepest struct/message nesting decoded. const MAX_DEPTH: usize = 64; /// Most values decoded from one message (every scalar, array element and field counts): at least -/// [`MIN_VALUES`], and one per [`PACKED_BYTES_PER_VALUE`] bytes of the file's compressed canvas, -/// so a large real file decodes while a small one can't expand into a huge tree, however far its -/// chunks inflate. +/// [`MIN_VALUES`], and one per byte of the file's compressed canvas up to [`MAX_VALUES`], so a large +/// real file decodes while a small one can't expand into a huge tree, however far its chunks +/// inflate. Real files hold 0.37 to 0.75 values per compressed byte. const MIN_VALUES: usize = 20_000_000; -const PACKED_BYTES_PER_VALUE: usize = 2; +const MAX_VALUES: usize = 128 << 20; /// Most definitions in a schema, and fields in one definition. const MAX_DEFS: usize = 10_000; const MAX_FIELDS: usize = 10_000; @@ -302,7 +302,7 @@ impl Schema { /// Most values decoded from a file whose compressed canvas is `packed` bytes. pub(crate) fn value_budget(packed: usize) -> usize { - MIN_VALUES.max(packed / PACKED_BYTES_PER_VALUE) + packed.clamp(MIN_VALUES, MAX_VALUES) } struct Decoder<'s, 'b> { diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 5f20f5ac..852700bf 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -798,8 +798,11 @@ fn large_files_get_a_larger_value_budget() { // A real 400 000-layer file: an 83.5 MB canvas whose message unpacks to 334 MB and holds // 30.5 million values was refused as "too many values". assert!(kiwi::value_budget(83_491_232) > 30_514_899); - // Still bounded by the largest canvas read. - assert!(kiwi::value_budget(MAX_CANVAS as usize) <= 128 << 20); + // A public design system packs more densely: a 39.5 MB canvas holding 29.7 million values, + // which one value per two compressed bytes refused. + assert!(kiwi::value_budget(39_500_000) > 29_703_730); + // Never more than the ceiling, however large the canvas. + assert_eq!(kiwi::value_budget(MAX_CANVAS as usize), 128 << 20); } #[test] From 01ce23ca765fabba5ec22bd83de5684614e259d3 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 15:58:23 +0700 Subject: [PATCH 107/118] #680: page refusal names the calling command (edit.copy / edit.cut) --- crates/engine/src/cmd/edit.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index ab503a02..22610934 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -496,13 +496,13 @@ fn bump_name(n: &str) -> String { } /// The copied form of the selection. -pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> { +pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<()> { let ds = s.ds()?; let doc = &ds.doc; // Pages are not layers: they can't be pasted anywhere. let layers: Vec = ids.iter().copied().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect(); if layers.is_empty() && !ids.is_empty() { - return Err(EngineError::Disabled("edit.copy".into(), "pages can't be copied; use page.duplicate".into())); + return Err(EngineError::Disabled(cmd.into(), "pages can't be copied; use page.duplicate".into())); } let ids = z_sorted(doc, &roots(doc, &layers)); let mut nodes = Vec::new(); @@ -540,13 +540,13 @@ pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> { fn copy(s: &mut Session, p: &Value) -> Result { let ids = req_targets("edit.copy", s, p)?; - copy_to_clipboard(s, &ids)?; + copy_to_clipboard(s, "edit.copy", &ids)?; Ok(json!({"copied": s.clipboard.nodes.len()})) } fn cut(s: &mut Session, p: &Value) -> Result { let ids = req_targets("edit.cut", s, p)?; - copy_to_clipboard(s, &ids)?; + copy_to_clipboard(s, "edit.cut", &ids)?; let doc = s.doc()?; let layers: Vec = ids.into_iter().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect(); delete(s, &json!({"ids": layers})) From 5ea5590673bff20d96097b37cc6e2177e9c69348 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 15:58:24 +0700 Subject: [PATCH 108/118] #680: move the page clipboard test to its own module, check the error id --- crates/engine/src/lib.rs | 2 ++ crates/engine/src/page_clipboard_tests.rs | 36 +++++++++++++++++++++++ crates/engine/src/tests.rs | 17 ----------- 3 files changed, 38 insertions(+), 17 deletions(-) create mode 100644 crates/engine/src/page_clipboard_tests.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6e0e8b12..9f00467a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1592,6 +1592,8 @@ mod open_issues_tests; #[cfg(test)] mod outline_options_tests; #[cfg(test)] +mod page_clipboard_tests; +#[cfg(test)] mod page_copy_navigation_tests; #[cfg(test)] mod page_target_tests; diff --git a/crates/engine/src/page_clipboard_tests.rs b/crates/engine/src/page_clipboard_tests.rs new file mode 100644 index 00000000..79ff7419 --- /dev/null +++ b/crates/engine/src/page_clipboard_tests.rs @@ -0,0 +1,36 @@ +//! `edit.copy` / `edit.cut` skip pages (they aren't layers and can't be pasted), and refuse a +//! selection of only pages under their own command id. + +use serde_json::json; + +use crate::{EngineError, Session}; + +#[test] +fn copy_and_cut_ignore_pages_so_paste_keeps_the_document_valid() { + for cmd in ["edit.copy", "edit.cut"] { + let mut s = Session::new(); + let page = s.page().unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + match s.execute(cmd, &json!({"ids": [page.0]})) { + Err(EngineError::Disabled(id, _)) => assert_eq!(id, cmd), + r => panic!("{cmd} on a page: {r:?}"), + } + let _ = s.execute("edit.paste", &json!({})); + let doc = s.doc().unwrap(); + assert!(doc.validate().is_empty(), "{cmd}: {:?}", doc.validate()); + assert!(doc.node(page).is_some(), "{cmd} removed the page"); + } +} + +#[test] +fn cut_of_a_page_and_a_layer_moves_only_the_layer() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let rect = crate::params::id_of(&r["id"]).unwrap(); + s.execute("edit.cut", &json!({"ids": [page.0, rect.0]})).unwrap(); + assert!(s.doc().unwrap().node(page).is_some()); + assert!(s.doc().unwrap().node(rect).is_none()); + s.execute("edit.paste", &json!({})).unwrap(); + assert!(s.doc().unwrap().validate().is_empty()); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6010559e..dbf432d4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -621,20 +621,3 @@ fn find_replace_changing_only_the_case_replaces_every_occurrence() { assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft"); assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft"); } - -#[test] -fn copy_and_cut_ignore_pages_so_paste_keeps_the_document_valid() { - for cmd in ["edit.copy", "edit.cut"] { - let mut s = sess(); - let page = s.page().unwrap(); - s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); - let r = s.execute(cmd, &json!({"ids": [page.0]})); - if let Ok(v) = &r { - assert!(v.get("deleted").is_none_or(|d| d == &json!([])), "{cmd} reported the page as deleted: {v}"); - } - let _ = s.execute("edit.paste", &json!({})); - let doc = s.doc().unwrap(); - assert!(doc.validate().is_empty(), "{cmd}: {:?}", doc.validate()); - assert!(doc.node(page).is_some(), "{cmd} removed the page"); - } -} From 6f0e14a27176a4399888d46955fe8a29445665c0 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:03:41 +0700 Subject: [PATCH 109/118] #673: read the failed step with get() instead of indexing --- apps/figcraft-cli/src/files.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 46c8dd22..3e08474a 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -350,7 +350,8 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe Ok(()) }); if let Err(e) = outcome { - let (i, (cmd, _)) = (failed_at, &steps[failed_at]); + let cmd = steps.get(failed_at).map_or("", |(cmd, _)| cmd.as_str()); + let i = failed_at; println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true})); eprintln!("error: step {i} ({cmd}) failed: {e}"); eprintln!("the script's document changes were rolled back"); From 1503f0743cbc440892dbc60b4dc8615d5d0f2955 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:06:26 +0700 Subject: [PATCH 110/118] #675: cap the offending value echoed in id/ids errors --- .../engine/src/id_params_behaviour_tests.rs | 10 ++++++++++ crates/engine/src/params.rs | 19 +++++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/id_params_behaviour_tests.rs b/crates/engine/src/id_params_behaviour_tests.rs index ae77c8a1..c5207b44 100644 --- a/crates/engine/src/id_params_behaviour_tests.rs +++ b/crates/engine/src/id_params_behaviour_tests.rs @@ -149,3 +149,13 @@ fn an_image_and_an_instance_parent_are_checked_the_same_way() { let inst = failed(s.execute("instance.create", &json!({"component": 99999, "parent": {"$step": 0}}))); assert!(inst.is_some(), "instance.create refuses both a bad component and a bad parent"); } + +#[test] +fn a_huge_bad_value_is_cut_short_in_the_error() { + let mut s = Session::new(); + card(&mut s); + let junk = "é".repeat(10_000); + let msg = failed(s.execute("node.set", &json!({"ids": junk, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("must fail")); + assert!(msg.contains("`ids` must be an array") && msg.ends_with("…)"), "{msg}"); + assert!(msg.chars().count() < 200, "{} chars", msg.chars().count()); +} diff --git a/crates/engine/src/params.rs b/crates/engine/src/params.rs index 67a5b80a..35a74e0e 100644 --- a/crates/engine/src/params.rs +++ b/crates/engine/src/params.rs @@ -88,6 +88,17 @@ pub fn req_id(cmd: &str, p: &Value, key: &str) -> Result { id(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id"))) } +/// The offending value for an error message, cut to a few dozen characters so a huge param +/// doesn't end up in the error whole. +fn shown(v: &Value) -> String { + const MAX: usize = 40; + let text = v.to_string(); + match text.char_indices().nth(MAX) { + Some((cut, _)) => format!("{}…", &text[..cut]), + None => text, + } +} + /// `key` when it is there: a layer id, or an error naming it. Missing and `null` mean "none", so a /// command can tell "no parent given" (use the page) from "a parent was given that isn't an id" — /// which is a mistake, not the default. Without this, `{"$step": 0}` reaching a param that isn't @@ -95,7 +106,7 @@ pub fn req_id(cmd: &str, p: &Value, key: &str) -> Result { pub fn opt_id(cmd: &str, p: &Value, key: &str) -> Result> { match get(p, key) { None | Some(Value::Null) => Ok(None), - Some(v) => id_of(v).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id (got {v})"))), + Some(v) => id_of(v).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id (got {})", shown(v)))), } } @@ -125,17 +136,17 @@ pub fn req_targets(cmd: &str, s: &Session, p: &Value) -> Result> { match get(p, "ids") { Some(Value::Array(a)) => { if let Some(v) = a.iter().find(|v| id_of(v).is_none()) { - return Err(bad(cmd, format!("`ids`: every entry must be a node id (got {v})"))); + return Err(bad(cmd, format!("`ids`: every entry must be a node id (got {})", shown(v)))); } } Some(Value::Null) | None => { if let Some(v) = get(p, "id").filter(|v| !v.is_null()) && id_of(v).is_none() { - return Err(bad(cmd, format!("`id` must be a node id (got {v})"))); + return Err(bad(cmd, format!("`id` must be a node id (got {})", shown(v)))); } } - Some(v) => return Err(bad(cmd, format!("`ids` must be an array (got {v})"))), + Some(v) => return Err(bad(cmd, format!("`ids` must be an array (got {})", shown(v)))), } let t = targets(s, p); if t.is_empty() { From 3c73045088480866d6dfd9bf9604a9513ced0677 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:11:59 +0700 Subject: [PATCH 111/118] #705: cap what a client buffers from a server at 1 GiB; warn the operator about files over 64 MiB when open and over what the app opens --- apps/figcraft-server/src/config.rs | 14 ++++++++++++++ apps/figcraft-server/src/public_tests.rs | 21 +++++++++++++++++---- crates/collab-net/src/limits.rs | 9 ++++----- docs/server.md | 2 +- 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/apps/figcraft-server/src/config.rs b/apps/figcraft-server/src/config.rs index 3e951e87..101c7815 100644 --- a/apps/figcraft-server/src/config.rs +++ b/apps/figcraft-server/src/config.rs @@ -135,9 +135,23 @@ impl Config { /// abuse contact, or one whose clients would all count as its proxy). pub fn warnings(&self) -> Vec { let mut out = Vec::new(); + let client_max = NetLimits::default().max_server_message >> 20; + if usize::try_from(self.file_mb()).is_ok_and(|mb| mb >= client_max) { + out.push(format!( + "max_file_mb is {}: the app opens files of up to {} MiB, so a file that large can't be opened", + self.file_mb(), + client_max - 1 + )); + } if !self.open_publish { return out; } + if self.file_mb() > 64 { + out.push(format!( + "open_publish is on with max_file_mb = {}: anyone may send messages that large, and the memory set aside for reading and queueing them grows to eight times it; keep it at 64 or less on a public server", + self.file_mb() + )); + } if self.operator_contact.trim().is_empty() { out.push("open_publish is on but operator_contact is empty: people can't reach you about abuse; set it (and terms_url)".into()); } diff --git a/apps/figcraft-server/src/public_tests.rs b/apps/figcraft-server/src/public_tests.rs index 6403212c..978d9e8e 100644 --- a/apps/figcraft-server/src/public_tests.rs +++ b/apps/figcraft-server/src/public_tests.rs @@ -506,8 +506,21 @@ fn files_above_64_mib_fit_every_limit() { assert!(net.max_reading_total >= net.max_reading_block && net.max_queued_total >= net.max_queued_block); assert!(core.max_message_bytes > file, "open={open_publish}: {}", core.max_message_bytes); } - // A client takes the largest file any server can keep, or as much as a 32-bit target can address - // (computed in u64: `4097 << 20` in a 32-bit usize wraps to 1 MiB). - let client = NetLimits::default().max_server_message as u64; - assert!(client > 4096 << 20 || client == u64::from(u32::MAX), "{client}"); + // A client takes such a file too, but caps what any server may make it buffer at 1 GiB. + assert_eq!(NetLimits::default().max_server_message, 1 << 30); +} + +#[test] +fn large_files_warn_the_operator() { + let public_url = Some("wss://figcraft.example.com/".into()); + let warns = |open_publish, mb| { + Config { open_publish, max_file_mb: Some(mb), public_url: public_url.clone(), ..Config::default() }.warnings().join( + " +", + ) + }; + assert!(!warns(true, 64).contains("max_file_mb"), "{}", warns(true, 64)); + assert!(warns(true, 65).contains("open_publish is on with max_file_mb = 65"), "{}", warns(true, 65)); + assert!(!warns(false, 1023).contains("max_file_mb"), "{}", warns(false, 1023)); + assert!(warns(false, 1024).contains("can't be opened"), "{}", warns(false, 1024)); } diff --git a/crates/collab-net/src/limits.rs b/crates/collab-net/src/limits.rs index 02167770..a0647fa6 100644 --- a/crates/collab-net/src/limits.rs +++ b/crates/collab-net/src/limits.rs @@ -12,10 +12,9 @@ pub struct NetLimits { /// before: its hello. pub max_client_message: usize, pub max_hello: usize, - /// Largest message a client accepts from the host (a welcome carries the whole document): the - /// largest file a `figcraft-server` can be set to keep (`max_file_mb`, at most 4096) and a margin, - /// so a client can open every file a server takes; on a 32-bit target (the browser) as much as - /// `usize` holds. + /// Largest message a client accepts from the host (a welcome carries the whole document): 1 GiB, + /// room for any file up to 1023 MiB and well past the servers' defaults, while a hostile server + /// (any link can name one) can't make the app or a browser tab buffer more than that. pub max_server_message: usize, /// Messages a client may send per second on average, and in a burst; beyond that it is /// disconnected. @@ -64,7 +63,7 @@ impl Default for NetLimits { max_connections: 64, max_client_message: figcraft_collab::Limits::default().max_message_bytes, max_hello: 64 << 10, - max_server_message: usize::try_from(4097u64 << 20).unwrap_or(usize::MAX), + max_server_message: 1 << 30, rate: 100.0, burst: 400.0, hello_timeout: Duration::from_secs(10), diff --git a/docs/server.md b/docs/server.md index 55d784f4..5cbab6a8 100644 --- a/docs/server.md +++ b/docs/server.md @@ -282,7 +282,7 @@ are errors): | `data` | `figcraft-data` | The data directory | | `public_url` | (the listen address) | How links name the server, when people reach it another way (a reverse proxy: `wss://figcraft.example.com/`) | | `max_files` | 1000 | Most files kept | -| `max_file_mb` | 64 (16 when open) | Largest file (its snapshot), in MiB; a welcome carries the whole file, so messages may be as large as a file and 1 MiB (at least 64 MiB on a private server) | +| `max_file_mb` | 64 (16 when open) | Largest file (its snapshot), in MiB; a welcome carries the whole file, so messages may be as large as a file and 1 MiB (at least 64 MiB on a private server). The app opens files of up to 1023 MiB; above 64 with `open_publish` the server warns | | `max_links` | 100 | Most share links per file | | `max_connections` | 512 | Most open connections | | `max_people` | 256 | Most people in one file at once | From 02b231db6dd9457097017ddc30d96d8a947c724d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:18:50 +0700 Subject: [PATCH 112/118] #712: read the invite link from the URL fragment (#join=), never sent to the web host; ignore overlong links --- apps/figcraft-web/src/main.rs | 10 ++++++---- apps/figcraft-web/src/query.rs | 21 +++++++++++-------- apps/figcraft-web/src/query_tests.rs | 30 ++++++++++++++++++---------- apps/figcraft-web/src/web.rs | 6 +++--- 4 files changed, 41 insertions(+), 26 deletions(-) diff --git a/apps/figcraft-web/src/main.rs b/apps/figcraft-web/src/main.rs index 35aebb02..7eed2ffb 100644 --- a/apps/figcraft-web/src/main.rs +++ b/apps/figcraft-web/src/main.rs @@ -13,10 +13,12 @@ //! (browsers block `ws://` from secure pages); serve it over `http://` on a trusted network to //! join from other machines. //! -//! URL query: `?webgl` forces the WebGL2 backend instead of WebGPU; `?join=` joins that live session once the page has loaded, so a bookmark opens a shared file. -//! `join` takes the rest of the query: put it last. The link carries the session's secret, so the -//! URL (and the browser history) is as private as the link itself. +//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU. +//! +//! URL fragment: `#join=` joins that live session once the page has +//! loaded, so a bookmark opens a shared file. `join` takes the rest of the fragment: put it last. +//! The link carries the session's secret; a fragment is never sent to the web host, but the address +//! bar and the browser history keep it, so the URL is as private as the link itself. #[cfg(any(target_arch = "wasm32", test))] mod query; diff --git a/apps/figcraft-web/src/query.rs b/apps/figcraft-web/src/query.rs index be68f598..befeb36b 100644 --- a/apps/figcraft-web/src/query.rs +++ b/apps/figcraft-web/src/query.rs @@ -1,10 +1,15 @@ -//! The page's URL query: which flags it sets and the invite link it names. +//! The page's URL: the invite link its fragment names. -/// The invite link in `?join=` (`search` is `location.search`, with or without its `?`), still -/// escaped. `join` takes the rest of the query, so a pasted `figcraft://join?host=…&file=…&token=…` -/// keeps its own `&`s; put it last. -pub fn join_link(search: &str) -> Option<&str> { - let query = search.strip_prefix('?').unwrap_or(search); - let start = if query.starts_with("join=") { 0 } else { query.find("&join=")? + 1 }; - query.get(start + "join=".len()..).filter(|link| !link.is_empty()) +/// The invite link in `#join=` (`fragment` is `location.hash`, with or without its `#`), still +/// escaped. The fragment, unlike the query, never leaves the browser, so the link's secret stays out +/// of the web host's logs and `Referer` headers. `join` takes the rest of the fragment, so a pasted +/// `figcraft://join?host=…&file=…&token=…` keeps its own `&`s. Anything longer than an escaped invite +/// link can be is ignored; what is left is checked by `collab.join` like a link the desktop app is +/// opened with. +pub fn join_link(fragment: &str) -> Option<&str> { + // An invite link is at most 2 KiB; every byte of it escaped is three. + const MAX: usize = 3 * 2048; + let fragment = fragment.strip_prefix('#').unwrap_or(fragment); + let start = if fragment.starts_with("join=") { 0 } else { fragment.find("&join=")? + 1 }; + fragment.get(start + "join=".len()..).filter(|link| !link.is_empty() && link.len() <= MAX) } diff --git a/apps/figcraft-web/src/query_tests.rs b/apps/figcraft-web/src/query_tests.rs index 9582682f..261ebaeb 100644 --- a/apps/figcraft-web/src/query_tests.rs +++ b/apps/figcraft-web/src/query_tests.rs @@ -1,21 +1,29 @@ -//! `?join=` in the page's URL. +//! `#join=` in the page's URL. use crate::query::join_link; #[test] -fn join_takes_the_rest_of_the_query() { +fn join_takes_the_rest_of_the_fragment() { let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv"; - assert_eq!(join_link(&format!("?join={link}")), Some(link), "a pasted link keeps its own &s"); - assert_eq!(join_link(&format!("?webgl&join={link}")), Some(link), "after other flags"); - assert_eq!(join_link("?join=figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), Some("figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), "escaped, decoded later"); - assert_eq!(join_link("join=K7M-2QX-9TD"), Some("K7M-2QX-9TD"), "a join code, no leading ?"); + assert_eq!(join_link(&format!("#join={link}")), Some(link), "a pasted link keeps its own &s"); + assert_eq!(join_link(&format!("#x=1&join={link}")), Some(link), "after other entries"); + assert_eq!(join_link("#join=figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), Some("figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), "escaped, decoded later"); + assert_eq!(join_link("join=K7M-2QX-9TD"), Some("K7M-2QX-9TD"), "a join code, no leading #"); } #[test] -fn no_join_without_the_parameter() { +fn no_join_without_the_entry() { assert_eq!(join_link(""), None); - assert_eq!(join_link("?webgl"), None); - assert_eq!(join_link("?join="), None); - assert_eq!(join_link("?rejoin=x"), None, "only the whole parameter name"); - assert_eq!(join_link("?xjoin=x&a=1"), None); + assert_eq!(join_link("#"), None); + assert_eq!(join_link("#join="), None); + assert_eq!(join_link("#rejoin=x"), None, "only the whole entry name"); + assert_eq!(join_link("#xjoin=x&a=1"), None); + assert_eq!(join_link("?join=x"), None, "the query isn't read: it goes to the web host"); +} + +#[test] +fn an_overlong_link_is_ignored() { + let long = format!("#join=figcraft://join?host=h&file=f&token={}", "a".repeat(7000)); + assert_eq!(join_link(&long), None); + assert_eq!(join_link(&format!("#join={}", "é".repeat(4000))), None, "length counts bytes"); } diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index d3c16441..29213621 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -41,9 +41,9 @@ pub fn start() { let ctx = cc.egui_ctx.clone(); session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint())))); let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() })); - // `?join=`: join that session, as opening an invite link does on the desktop. - let search = query(); - if let Some(link) = crate::query::join_link(&search) { + // `#join=`: join that session, as opening an invite link does on the desktop. + let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default(); + if let Some(link) = crate::query::join_link(&hash) { let link = js_sys::decode_uri_component(link).ok().and_then(|s| s.as_string()).unwrap_or_else(|| link.to_string()); figcraft_ui_egui::live::open_invite(&mut fig, &link); } From 532b0f7321cf315c3b79bff36cc8e7d194c7b3de Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:18:57 +0700 Subject: [PATCH 113/118] Batch 11: Paste here puts copied design layers at the point --- crates/ui-egui/src/design_paste_tests.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/ui-egui/src/design_paste_tests.rs b/crates/ui-egui/src/design_paste_tests.rs index c320230a..35743a81 100644 --- a/crates/ui-egui/src/design_paste_tests.rs +++ b/crates/ui-egui/src/design_paste_tests.rs @@ -45,6 +45,16 @@ fn paste_takes_layers_copied_in_another_design_app() { assert_eq!(selected_names(&app), ["Copied layer"]); } +#[test] +fn paste_here_puts_copied_design_layers_at_the_point() { + let html = copied_html(); + let mut app = FigApp::new(Session::new(), Box::new(Clip { html, text: Some(String::new()) })); + crate::menus::paste(&mut app, None, Some((300.0, 200.0))); + let id = app.session.selection()[0]; + let b = app.session.doc().unwrap().world_bounds(id).unwrap(); + assert_eq!((b.x0, b.y0), (300.0, 200.0)); +} + #[test] fn html_from_other_apps_pastes_as_before() { let clip = Clip { html: "Hello".into(), text: Some("Hello".into()) }; From b6d98e28dccc8303c252e590a6c802f380c5cced Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:29:31 +0700 Subject: [PATCH 114/118] #699: keep en.toml's end of file as on main --- crates/ui-egui/locales/en.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 53cc5c45..efd23d04 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1606,4 +1606,4 @@ name = "English" "⌘ arrows" = "⌘ arrows" "⌘ click" = "⌘ click" "⌘ scroll" = "⌘ scroll" -"⌥ drag" = "⌥ drag" +"⌥ drag" = "⌥ drag" \ No newline at end of file From 7f7921260ca81914d48e6a69d61c01431f28f611 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:29:34 +0700 Subject: [PATCH 115/118] #699: menu input dialog and language picker use the shared widgets; File > Rename edits the file name in place --- crates/ui-egui/src/dialogs.rs | 70 +++++++++++++++----------- crates/ui-egui/src/layers.rs | 3 +- crates/ui-egui/src/lib.rs | 3 ++ crates/ui-egui/src/menu_input_tests.rs | 27 +++++++--- crates/ui-egui/src/menus.rs | 7 +-- 5 files changed, 69 insertions(+), 41 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 31eb1adb..649bcb3f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -74,7 +74,6 @@ pub enum AboutTab { /// Values collected by a menu or Actions command before dispatching it to the engine. #[derive(Clone, Debug, PartialEq)] pub enum MenuInput { - RenameFile(String), Link(String), Offset(f64), MoveToPage, @@ -274,18 +273,27 @@ pub fn show(app: &mut FigApp, ctx: &Context) { ui.horizontal(|ui| { ui.label(tr("Language")); // Each language by its own name; System follows the OS (the browser on the web). - let langs = i18n::languages().iter().filter(|l| crate::cjk::can_show(l.code)).map(|l| (l.code, l.name)); - let choices: Vec<_> = [("system", tr("System"))].into_iter().chain(langs).collect(); - let selected = - choices.iter().find(|(code, _)| app.session.prefs.language.eq_ignore_ascii_case(code)).map_or(tr("System"), |(_, name)| *name); - egui::ComboBox::from_id_salt("pref_language").selected_text(selected).width(220.0).show_ui(ui, |ui| { - for (code, name) in choices { - if ui.selectable_label(app.session.prefs.language.eq_ignore_ascii_case(code), name).clicked() { - app.run("pref.language", json!({"language": code})); - ui.ctx().request_repaint(); + let langs = || { + [("system", tr("System"))] + .into_iter() + .chain(i18n::languages().iter().filter(|l| crate::cjk::can_show(l.code)).map(|l| (l.code, l.name))) + }; + let on = |code: &str| app.session.prefs.language.eq_ignore_ascii_case(code); + let selected = langs().find(|(code, _)| on(code)).map_or(tr("System"), |(_, name)| name); + let resp = crate::widgets::dropdown(ui, selected, 220.0, None); + let mut pick = None; + crate::right::menu_popup(&resp).show(|ui| { + for (code, name) in langs() { + if crate::right::menu_check(ui, on(code), name, "") { + pick = Some(code); + ui.close(); } } }); + if let Some(code) = pick { + app.run("pref.language", json!({"language": code})); + ui.ctx().request_repaint(); + } }); if let Some(lang) = i18n::current().filter(|l| l.draft) { let note = trf("{language}: draft translation, waiting for a native speaker's review.", &[("language", &lang.name)]); @@ -355,8 +363,8 @@ pub fn show(app: &mut FigApp, ctx: &Context) { /// command so the same validation and undo behaviour apply as for programmatic callers. fn menu_input_dialog(app: &mut FigApp, ctx: &Context) { let Some(mut input) = app.ui.menu_input.take() else { return }; + let t = Tokens::get(ctx); let title = match &input { - MenuInput::RenameFile(_) => i18n::command("file.rename", "Rename file"), MenuInput::Link(_) => i18n::command("text.link", "Create link"), MenuInput::Offset(_) => i18n::command("vector.offset", "Offset path"), MenuInput::MoveToPage => i18n::command("object.moveToPage", "Move to page"), @@ -366,26 +374,28 @@ fn menu_input_dialog(app: &mut FigApp, ctx: &Context) { window(&title, ctx).open(&mut open).show(ctx, |ui| { ui.set_width(320.0); match &mut input { - MenuInput::RenameFile(name) => { - let field = ui.add(egui::TextEdit::singleline(name).desired_width(300.0)); - if (ui.button(tr("Rename")).clicked() || field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) - && !name.trim().is_empty() - { - action = Some(("file.rename", json!({"name": name.trim()}))); - } - } MenuInput::Link(url) => { - let field = ui.add(egui::TextEdit::singleline(url).hint_text("https://").desired_width(300.0)); - if (ui.button(tr("Done")).clicked() || field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) && !url.trim().is_empty() - { - action = Some(("text.link", json!({"url": url.trim()}))); - } + ui.horizontal(|ui| { + let field = crate::widgets::text_input(ui, egui::Id::new("menu_input_link"), url, 250.0, "https://"); + let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + let ok = !url.trim().is_empty(); + if (crate::widgets::button(ui, tr("Done"), true, ok).clicked() || enter) && ok { + action = Some(("text.link", json!({"url": url.trim()}))); + } + }); } MenuInput::Offset(offset) => { - ui.add(egui::DragValue::new(offset).speed(1.0).range(-100_000.0..=100_000.0)); - if ui.button(tr("Offset")).clicked() { - action = Some(("vector.offset", json!({"offset": offset}))); - } + ui.horizontal(|ui| { + let id = egui::Id::new("menu_input_offset"); + if let Some(crate::widgets::FieldEdit::Set(v) | crate::widgets::FieldEdit::Scrub(v, _)) = + crate::widgets::number_field(ui, id, crate::widgets::Lead::None, Some(*offset), "", 120.0, 1.0) + { + *offset = v.clamp(-100_000.0, 100_000.0); + } + if crate::widgets::button(ui, tr("Offset"), true, true).clicked() { + action = Some(("vector.offset", json!({"offset": *offset}))); + } + }); } MenuInput::MoveToPage => { let mut choices = 0; @@ -395,7 +405,7 @@ fn menu_input_dialog(app: &mut FigApp, ctx: &Context) { continue; } if let Some(node) = ds.doc.node(page) - && ui.button(&node.name).clicked() + && crate::widgets::button(ui, &node.name, false, true).clicked() { action = Some(("object.moveToPage", json!({"page": page}))); } @@ -403,7 +413,7 @@ fn menu_input_dialog(app: &mut FigApp, ctx: &Context) { } } if choices == 0 { - ui.label(tr("No other pages")); + crate::widgets::label(ui, tr("No other pages"), t.text_secondary); } } } diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 8b614762..f05c9f87 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -148,7 +148,8 @@ pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { } else { let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_default(); let nr = Rect::from_min_max(pos2(head.min.x + 14.0, head.min.y + 14.0), pos2(head.max.x - 48.0, head.min.y + 34.0)); - if let Some(new) = crate::widgets::text_field_inline(ui, Id::new("filename"), &name, nr, semibold(13.0)) { + let start = std::mem::take(&mut app.ui.rename_file); + if let (Some(new), _) = crate::widgets::rename_field(ui, Id::new("filename"), &name, nr, semibold(13.0), start) { app.run("file.rename", json!({"name": new})); } ui.painter().text(pos2(head.min.x + 16.0, head.min.y + 46.0), Align2::LEFT_CENTER, tr("Drafts"), regular(11.0), t.text_secondary); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c4671391..982c275f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -414,6 +414,8 @@ pub struct UiState { pub preferences: bool, /// A menu command that needs a value before the engine can run it. pub menu_input: Option, + /// File ▸ Rename: the file name in the left panel starts its inline rename on the next frame. + pub rename_file: bool, pub shortcuts_panel: bool, /// The Keyboard shortcuts panel's tab and search text. pub shortcuts_tab: figcraft_engine::keymap::Group, @@ -542,6 +544,7 @@ impl Default for UiState { about_tab: dialogs::AboutTab::About, preferences: false, menu_input: None, + rename_file: false, shortcuts_panel: false, shortcuts_tab: figcraft_engine::keymap::Group::Essential, shortcuts_query: String::new(), diff --git a/crates/ui-egui/src/menu_input_tests.rs b/crates/ui-egui/src/menu_input_tests.rs index 0de3757f..f317b65b 100644 --- a/crates/ui-egui/src/menu_input_tests.rs +++ b/crates/ui-egui/src/menu_input_tests.rs @@ -31,13 +31,9 @@ fn preference_actions_open_preferences_without_changing_values() { #[test] fn menu_entries_collect_required_arguments_before_running() { let mut ui = Ui::new(Session::new()); - let name = ui.app.session.doc().unwrap().name.clone(); - for (id, expected) in [ - ("file.rename", MenuInput::RenameFile(name)), - ("text.link", MenuInput::Link(String::new())), - ("vector.offset", MenuInput::Offset(0.0)), - ("object.moveToPage", MenuInput::MoveToPage), - ] { + for (id, expected) in + [("text.link", MenuInput::Link(String::new())), ("vector.offset", MenuInput::Offset(0.0)), ("object.moveToPage", MenuInput::MoveToPage)] + { ui.app.ui.menu_input = None; menus::invoke(&mut ui.app, id); assert_eq!(ui.app.ui.menu_input, Some(expected), "{id}"); @@ -72,3 +68,20 @@ fn batch_rename_opens_its_existing_dialog() { assert_eq!(ui.app.ui.batch_rename.as_ref().map(|d| d.ids.len()), Some(1)); assert!(ui.app.ui.last_error.is_none()); } + +#[test] +fn rename_file_edits_the_file_name_in_place() { + let mut ui = Ui::new(Session::new()); + ui.app.ui.left_collapsed = true; + menus::invoke(&mut ui.app, "file.rename"); + assert!(!ui.app.ui.left_collapsed, "the left panel shows the file name"); + assert!(ui.app.ui.menu_input.is_none(), "no dialog"); + ui.frame(vec![]); + ui.frame(vec![]); + assert!(!ui.app.ui.rename_file, "the rename started"); + assert!(ui.ctx.memory(|m| m.has_focus(egui::Id::new("filename").with("te"))), "the file name's editor has the keyboard"); + ui.frame(vec![egui::Event::Text("Brand kit".into())]); + ui.frame(vec![egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]); + assert_eq!(ui.app.session.doc().unwrap().name, "Brand kit"); + assert!(ui.app.ui.last_error.is_none()); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 18023e67..5a9017a8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -137,10 +137,11 @@ pub fn invoke(app: &mut FigApp, id: &str) { "pref.language" | "pref.userName" | "pref.publishServer" | "pref.nudge" => { app.run("app.preferences", json!({})); } + // The file name in the left panel's header turns into its editor, the name selected. "file.rename" => { - if let Ok(doc) = app.session.doc() { - app.ui.menu_input = Some(crate::dialogs::MenuInput::RenameFile(doc.name.clone())); - } + app.ui.left_tab = crate::LeftTab::File; + app.ui.left_collapsed = false; + app.ui.rename_file = true; } "text.link" => app.ui.menu_input = Some(crate::dialogs::MenuInput::Link(String::new())), "vector.offset" => app.ui.menu_input = Some(crate::dialogs::MenuInput::Offset(0.0)), From 234339a78681c634075336e442ffe1f3685d706b Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:47:07 +0000 Subject: [PATCH 116/118] Refuse pages in edit.copy and edit.cut Found while testing crates/engine/src/cmd/edit.rs:499 (copy_to_clipboard, called from copy at :536 and cut at :542) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/edit.rs | 11 +++++++++-- crates/engine/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 21f09569..eef4beaa 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -505,7 +505,12 @@ fn bump_name(n: &str) -> String { /// The copied form of the selection. pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> { let ds = s.ds()?; - let (nodes, images, bounds) = copied(&ds.doc, ids); + // Pages are not layers: they can't be pasted anywhere. + let layers: Vec = ids.iter().copied().filter(|i| !ds.doc.node(*i).is_some_and(Node::is_page)).collect(); + if layers.is_empty() && !ids.is_empty() { + return Err(EngineError::Disabled("edit.copy".into(), "pages can't be copied; use page.duplicate".into())); + } + let (nodes, images, bounds) = copied(&ds.doc, &layers); let (source, source_path, uid) = (ds.doc.clone(), ds.path.clone(), ds.uid); s.clipboard.source = Some(source); s.clipboard.source_path = source_path; @@ -605,7 +610,9 @@ fn copy(s: &mut Session, p: &Value) -> Result { fn cut(s: &mut Session, p: &Value) -> Result { let ids = req_targets("edit.cut", s, p)?; copy_to_clipboard(s, &ids)?; - delete(s, &json!({"ids": ids})) + let doc = s.doc()?; + let layers: Vec = ids.into_iter().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect(); + delete(s, &json!({"ids": layers})) } /// Paste the clipboard under `parent` with the copied top-left moved to `at` (page coords). diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dbf432d4..6010559e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -621,3 +621,20 @@ fn find_replace_changing_only_the_case_replaces_every_occurrence() { assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft"); assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft"); } + +#[test] +fn copy_and_cut_ignore_pages_so_paste_keeps_the_document_valid() { + for cmd in ["edit.copy", "edit.cut"] { + let mut s = sess(); + let page = s.page().unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let r = s.execute(cmd, &json!({"ids": [page.0]})); + if let Ok(v) = &r { + assert!(v.get("deleted").is_none_or(|d| d == &json!([])), "{cmd} reported the page as deleted: {v}"); + } + let _ = s.execute("edit.paste", &json!({})); + let doc = s.doc().unwrap(); + assert!(doc.validate().is_empty(), "{cmd}: {:?}", doc.validate()); + assert!(doc.node(page).is_some(), "{cmd} removed the page"); + } +} From fec54d93619a9356fbefedd50c4f5993ad1f4ec7 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:36:54 +0700 Subject: [PATCH 117/118] #599: leave the progress docs to the maintainer --- ROADMAP.md | 2 +- docs/localization-parity.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2411799f..006b81b9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -111,7 +111,7 @@ native speakers' reviews of the six language drafts need people. | Korean | ko | 100% | draft (needs native review) | | Vietnamese | vi | 0% | none | -Also shipped: Swedish (`sv`: every UI string, command label and menu wording; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese +Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI diff --git a/docs/localization-parity.md b/docs/localization-parity.md index d3f69a9a..45a7a925 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -100,8 +100,8 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v | Korean | ko | 2,076 / 2,076 (100%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | | Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics | no | none | 3–5 | -Other languages: Swedish (`sv`): every UI string, command label and menu wording translated; native -review welcome. Figma ships none beyond the eight +Other languages: Swedish (`sv`): every UI string translated, and 120 of the 588 command labels and +menu wordings (the rest show in English); native review welcome. Figma ships none beyond the eight above. The layer is done. Per-language hours above are agent hours for a catalog of ~1,800 entries From e912a2efe8b2574b9835caf629c7a871fb436bb7 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:38:35 +0700 Subject: [PATCH 118/118] #655: fill.set / stroke.set read index with params::opt_index (#675) --- crates/engine/src/cmd/edit.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 1d7cb88e..12ff561e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1184,7 +1184,7 @@ fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result Result { let cmd = if fills { "fill.set" } else { "stroke.set" }; let ids = req_targets(cmd, s, p)?; - let idx = if get(p, "index").is_none() { None } else { Some(req_index(cmd, p, "index")?) }; + let idx = opt_index(cmd, p, "index")?; let new_paint = match get(p, "paint") { Some(v) => Some(paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?), None => None,