From 75a47d66e28f32f045098fd1512b6fc4edcca063 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 4 Nov 2026 19:46:53 -0500 Subject: [PATCH] Release 0.21.1 --- .github/workflows/release.yml | 1 + ASSETS.md | 34 +- Cargo.lock | 51 +- Cargo.toml | 28 +- NOTICE | 4 +- ROADMAP.md | 9 +- apps/vectorcraft-web/src/keys.rs | 40 + apps/vectorcraft-web/src/web.rs | 2 + apps/vectorcraft/Cargo.toml | 5 +- apps/vectorcraft/src/clipboard.rs | 24 +- apps/vectorcraft/src/control_server.rs | 28 + apps/vectorcraft/src/control_server/tests.rs | 14 +- apps/vectorcraft/src/gpu.rs | 39 +- apps/vectorcraft/src/gpu/tests.rs | 19 +- apps/vectorcraft/src/host_services.rs | 447 +++- apps/vectorcraft/src/portal_save.rs | 19 +- apps/vectorcraft/src/prefs.rs | 22 +- apps/vectorcraft/src/profile.rs | 31 +- apps/vectorcraft/src/single_instance.rs | 9 +- contributors/people.toml | 20 + crates/affinity/src/container.rs | 91 +- crates/affinity/src/overlay.rs | 46 +- crates/affinity/src/shapes.rs | 12 +- crates/affinity/src/stream.rs | 97 +- crates/affinity/src/tests.rs | 28 + crates/brush/src/lib.rs | 26 +- crates/brush/src/tests.rs | 41 + crates/brush/src/track.rs | 3 +- crates/brush/src/warp.rs | 2 +- crates/cad/src/import/build.rs | 92 +- crates/cad/src/import/tests.rs | 172 +- crates/cad/src/scene.rs | 224 +- crates/cad/src/tests.rs | 227 +- crates/color/src/cms/icc.rs | 23 + crates/color/src/cms/tests_icc.rs | 26 + crates/color/src/gradient.rs | 75 +- crates/color/src/gradient/tests.rs | 77 + crates/color/src/recolor.rs | 11 +- crates/doc/src/appearance/profile.rs | 41 +- crates/doc/src/blend.rs | 46 + crates/doc/src/depth.rs | 45 +- crates/doc/src/document_ext.rs | 124 +- crates/doc/src/document_guides.rs | 70 +- crates/doc/src/graph.rs | 7 + crates/doc/src/hit.rs | 93 +- crates/doc/src/hit/tests.rs | 220 ++ crates/doc/src/lib.rs | 13 +- crates/doc/src/limits.rs | 4 +- crates/doc/src/mem.rs | 45 +- crates/doc/src/node/ops.rs | 64 +- crates/doc/src/node/tests.rs | 59 +- crates/doc/src/objects_on_path.rs | 340 ++- crates/doc/src/orient.rs | 14 +- crates/doc/src/pattern.rs | 102 +- crates/doc/src/perspective.rs | 2 +- crates/doc/src/placed_document.rs | 3 + crates/doc/src/range.rs | 46 +- crates/doc/src/reach.rs | 62 +- crates/doc/src/skip.rs | 5 + crates/doc/src/style_libs.rs | 12 +- crates/doc/src/swatches.rs | 27 +- crates/doc/src/symbol_nine_slice.rs | 197 +- crates/doc/src/symbols.rs | 174 +- crates/doc/src/tests.rs | 61 + crates/doc/src/text.rs | 2 +- crates/doc/src/text/char_style.rs | 28 + crates/doc/src/text/object.rs | 10 +- crates/doc/src/text/tests.rs | 13 + crates/doc/src/twist.rs | 231 ++ crates/doc/src/twist/tests.rs | 186 ++ crates/effects/src/group/tests.rs | 17 + crates/effects/src/lib.rs | 2 +- crates/effects/src/raster.rs | 2 +- crates/effects/src/stroke.rs | 30 +- crates/effects/src/stroke/dash.rs | 36 +- crates/effects/src/stroke/tests.rs | 79 + crates/effects/src/stroke/width.rs | 7 +- crates/engine/Cargo.toml | 2 + crates/engine/examples/anchor_bench.rs | 336 +++ crates/engine/src/cmd.rs | 46 +- crates/engine/src/cmd/addons.rs | 4 + crates/engine/src/cmd/addons/color.rs | 2 +- crates/engine/src/cmd/addons/document.rs | 17 +- .../src/cmd/addons/document/batch_trace.rs | 127 ++ .../engine/src/cmd/addons/document/tests.rs | 76 +- crates/engine/src/cmd/addons/select.rs | 63 + crates/engine/src/cmd/addons/select/tests.rs | 128 ++ crates/engine/src/cmd/addons/typetools.rs | 189 ++ .../engine/src/cmd/addons/typetools/tests.rs | 223 ++ crates/engine/src/cmd/appearance.rs | 179 +- crates/engine/src/cmd/brushlib.rs | 11 +- crates/engine/src/cmd/brushsym.rs | 10 +- crates/engine/src/cmd/brushsym/symbols.rs | 85 +- crates/engine/src/cmd/buildcmds.rs | 6 +- crates/engine/src/cmd/clipboard.rs | 34 +- crates/engine/src/cmd/clipboard/flavours.rs | 34 +- crates/engine/src/cmd/clipboard/resources.rs | 68 +- crates/engine/src/cmd/colorcmds.rs | 2 +- crates/engine/src/cmd/colormgmt.rs | 245 +- crates/engine/src/cmd/colortheme.rs | 2 +- crates/engine/src/cmd/create.rs | 3 +- crates/engine/src/cmd/cut.rs | 102 +- crates/engine/src/cmd/docinfo.rs | 80 +- crates/engine/src/cmd/docmenu.rs | 74 +- crates/engine/src/cmd/docsetup.rs | 2 +- crates/engine/src/cmd/edit.rs | 105 +- crates/engine/src/cmd/effectcmd.rs | 26 +- crates/engine/src/cmd/expand.rs | 124 +- crates/engine/src/cmd/fileio.rs | 4 +- crates/engine/src/cmd/fileio/access.rs | 8 +- .../src/cmd/fileio/affinity/master_copies.rs | 57 +- .../engine/src/cmd/fileio/affinity/nodes.rs | 37 + crates/engine/src/cmd/fileio/batch.rs | 27 +- crates/engine/src/cmd/fileio/commands.rs | 2 +- crates/engine/src/cmd/fileio/encode.rs | 5 +- crates/engine/src/cmd/fileio/legacy_ai.rs | 12 +- crates/engine/src/cmd/fileio/load.rs | 178 +- crates/engine/src/cmd/fileio/naming.rs | 16 +- crates/engine/src/cmd/fileio/native.rs | 56 +- crates/engine/src/cmd/fileio/pdf.rs | 17 +- crates/engine/src/cmd/fileio/pdfimport.rs | 62 +- crates/engine/src/cmd/fileio/pxrread.rs | 13 +- crates/engine/src/cmd/fileio/save.rs | 146 +- crates/engine/src/cmd/fileio/screens.rs | 111 +- crates/engine/src/cmd/fileio/tests.rs | 14 +- .../engine/src/cmd/fileio/tests_aiimport.rs | 221 +- .../src/cmd/fileio/tests_artboard_fill.rs | 12 + crates/engine/src/cmd/fileio/tests_pdfedit.rs | 220 ++ .../src/cmd/fileio/tests_pdffidelity.rs | 2 + crates/engine/src/cmd/fileio/tests_pxrread.rs | 14 + crates/engine/src/cmd/flatten/analysis.rs | 171 +- crates/engine/src/cmd/flatten/apply.rs | 58 +- crates/engine/src/cmd/fonts.rs | 154 +- crates/engine/src/cmd/freeform.rs | 4 +- crates/engine/src/cmd/globaledit.rs | 60 +- crates/engine/src/cmd/gradient.rs | 49 +- crates/engine/src/cmd/graph.rs | 2 +- crates/engine/src/cmd/graph/designs.rs | 2 + crates/engine/src/cmd/graph/generate.rs | 9 +- crates/engine/src/cmd/graph/series.rs | 72 +- crates/engine/src/cmd/graph/tests.rs | 249 ++ crates/engine/src/cmd/halftone.rs | 2 +- crates/engine/src/cmd/layer.rs | 175 +- crates/engine/src/cmd/library.rs | 11 +- crates/engine/src/cmd/links.rs | 6 +- crates/engine/src/cmd/live.rs | 2 +- crates/engine/src/cmd/live/commands.rs | 4 +- crates/engine/src/cmd/live/mesh.rs | 6 +- crates/engine/src/cmd/menucmds.rs | 121 +- crates/engine/src/cmd/objects_on_path.rs | 28 +- crates/engine/src/cmd/paint.rs | 51 +- crates/engine/src/cmd/panelcmds.rs | 7 +- crates/engine/src/cmd/params.rs | 25 +- crates/engine/src/cmd/path.rs | 31 +- crates/engine/src/cmd/pdfcmds.rs | 8 +- crates/engine/src/cmd/place/document.rs | 3 +- crates/engine/src/cmd/plugin.rs | 4 +- crates/engine/src/cmd/prefscmds.rs | 2 + crates/engine/src/cmd/printpresets.rs | 8 +- crates/engine/src/cmd/recolor.rs | 7 +- crates/engine/src/cmd/recovery.rs | 26 +- crates/engine/src/cmd/select.rs | 75 +- crates/engine/src/cmd/slices.rs | 12 +- crates/engine/src/cmd/stroke.rs | 2 +- crates/engine/src/cmd/swatch.rs | 24 +- crates/engine/src/cmd/swatchlib.rs | 39 +- crates/engine/src/cmd/symbollib.rs | 43 +- crates/engine/src/cmd/tabs.rs | 71 + crates/engine/src/cmd/textedit.rs | 552 ++++- crates/engine/src/cmd/textstyles.rs | 132 +- crates/engine/src/cmd/textstyles/tests.rs | 247 +- crates/engine/src/cmd/threads.rs | 37 +- crates/engine/src/cmd/threads/tests.rs | 41 + crates/engine/src/cmd/typecmd.rs | 149 +- crates/engine/src/cmd/typecmd/area_tests.rs | 74 + crates/engine/src/cmd/variablecmds.rs | 14 +- crates/engine/src/cmd/variablecmds/csv.rs | 127 +- crates/engine/src/cmd/webexport.rs | 9 +- crates/engine/src/cmd/xform.rs | 2 +- crates/engine/src/doc_state.rs | 14 + crates/engine/src/file_access.rs | 29 +- crates/engine/src/history.rs | 60 +- crates/engine/src/interaction.rs | 13 + crates/engine/src/lib.rs | 17 + crates/engine/src/link_watch.rs | 48 +- crates/engine/src/prefs.rs | 5 + crates/engine/src/session/documents.rs | 14 + crates/engine/src/session/edit.rs | 48 +- crates/engine/src/session/execute.rs | 24 +- crates/engine/src/session/undo.rs | 39 +- crates/engine/src/tests.rs | 3 + crates/engine/src/tests/ai_open_path.rs | 58 + crates/engine/src/tests/appearance.rs | 152 +- crates/engine/src/tests/areafit.rs | 2 +- crates/engine/src/tests/assets.rs | 18 + crates/engine/src/tests/blendspine.rs | 36 + crates/engine/src/tests/brushsym.rs | 99 + crates/engine/src/tests/build.rs | 84 + crates/engine/src/tests/charstroke.rs | 15 + crates/engine/src/tests/clipflavours.rs | 21 + crates/engine/src/tests/cmdsplit.rs | 22 + crates/engine/src/tests/colormgmt.rs | 147 ++ crates/engine/src/tests/expand.rs | 23 + crates/engine/src/tests/expand_symbols.rs | 286 +++ crates/engine/src/tests/flatten.rs | 27 + crates/engine/src/tests/freeform.rs | 26 + crates/engine/src/tests/globaledit.rs | 132 ++ crates/engine/src/tests/gradient.rs | 41 + crates/engine/src/tests/gradpresets.rs | 177 ++ crates/engine/src/tests/guide_clipboard.rs | 131 ++ crates/engine/src/tests/history_mem.rs | 39 + crates/engine/src/tests/layers.rs | 26 + crates/engine/src/tests/legacy_ai.rs | 61 + crates/engine/src/tests/library.rs | 12 + crates/engine/src/tests/link_paths.rs | 7 +- crates/engine/src/tests/linked_stops.rs | 54 + crates/engine/src/tests/linkspanel.rs | 18 + crates/engine/src/tests/linkwatch.rs | 17 +- crates/engine/src/tests/live.rs | 22 + crates/engine/src/tests/menucmds.rs | 484 +++- crates/engine/src/tests/nativefile.rs | 34 + crates/engine/src/tests/objects_on_path.rs | 88 + crates/engine/src/tests/opacitymask.rs | 27 +- crates/engine/src/tests/paintproxy.rs | 13 + crates/engine/src/tests/panelcmds.rs | 262 +++ crates/engine/src/tests/params.rs | 21 + crates/engine/src/tests/pattern.rs | 68 +- crates/engine/src/tests/place.rs | 33 + crates/engine/src/tests/placed_document.rs | 113 + crates/engine/src/tests/plugins.rs | 20 + crates/engine/src/tests/prefs.rs | 47 + crates/engine/src/tests/recolor.rs | 6 + crates/engine/src/tests/recovery.rs | 40 + crates/engine/src/tests/saveoptions.rs | 205 +- crates/engine/src/tests/slices.rs | 15 + crates/engine/src/tests/styles.rs | 26 + crates/engine/src/tests/swatches.rs | 764 +++++++ crates/engine/src/tests/symbol_nine_slice.rs | 45 +- crates/engine/src/tests/textedit.rs | 1027 ++++++++- crates/engine/src/tests/toolsettings.rs | 110 + crates/engine/src/tests/typearea.rs | 22 + crates/engine/src/tests/typescale.rs | 24 + crates/engine/src/tests/variables.rs | 384 ++-- crates/engine/src/tests/webexport.rs | 2 + crates/engine/src/tests/xform.rs | 21 + crates/engine/src/tooling.rs | 148 +- crates/engine/tests/command_sweep.rs | 9 + crates/engine/tests/import_fuzz.rs | 196 +- crates/eps/Cargo.toml | 2 + crates/eps/src/ai_editing.rs | 393 ++++ crates/eps/src/ai_editing/tests.rs | 1676 ++++++++++++++ crates/eps/src/ai_editing/writer.rs | 1165 ++++++++++ crates/eps/src/ai_ops.rs | 97 + crates/eps/src/import.rs | 3 +- crates/eps/src/import/ai.rs | 7 +- crates/eps/src/import/ai/blend.rs | 192 +- crates/eps/src/import/ai/brush.rs | 343 +++ crates/eps/src/import/ai/legacy_text.rs | 3 + crates/eps/src/import/ai/read.rs | 87 +- crates/eps/src/import/ai/read/art.rs | 20 +- crates/eps/src/import/ai/read/containers.rs | 20 +- crates/eps/src/import/ai/read/directives.rs | 398 +++- crates/eps/src/import/ai/read/document.rs | 263 ++- crates/eps/src/import/ai/read/on_path.rs | 2 +- crates/eps/src/import/ai/read/ops.rs | 150 +- crates/eps/src/import/ai/style.rs | 91 +- crates/eps/src/import/ai/style/tests.rs | 138 ++ crates/eps/src/import/ai/tests.rs | 779 ++++++- crates/eps/src/import/data/filters.rs | 12 +- crates/eps/src/import/data/ops.rs | 140 +- crates/eps/src/import/interp.rs | 73 +- crates/eps/src/import/native.rs | 77 +- crates/eps/src/import/native/build.rs | 617 ++++- crates/eps/src/import/native/compare.rs | 868 ++++++- crates/eps/src/import/native/layers.rs | 115 +- crates/eps/src/import/native/plan.rs | 178 +- crates/eps/src/import/native/slots.rs | 154 +- crates/eps/src/import/obj.rs | 5 + crates/eps/src/import/tests.rs | 43 +- crates/eps/src/import/tests_generators.rs | 15 + crates/eps/src/import/tests_illustrator.rs | 26 + crates/eps/src/import/tests_images.rs | 54 + crates/eps/src/import/tests_native.rs | 897 ++++++++ crates/eps/src/legacy_ai.rs | 8 +- crates/eps/src/legacy_ai/encoding.rs | 15 +- crates/eps/src/legacy_ai/writer.rs | 83 +- crates/eps/src/lib.rs | 9 +- crates/format/src/atomic.rs | 26 +- crates/format/src/lib.rs | 142 +- crates/format/tests/it/native_v3.rs | 18 + crates/format/tests/it/save_options.rs | 212 +- crates/geom/src/bez.rs | 9 + crates/geom/src/lib.rs | 24 +- crates/geom/src/path.rs | 158 +- crates/geom/src/projective.rs | 467 +++- crates/mcp/src/headless.rs | 5 +- crates/mcp/src/intake.rs | 7 +- crates/mcp/src/tests.rs | 11 +- crates/mcp/src/tests/fileio.rs | 20 +- crates/mcp/src/tools/definitions.rs | 5 +- crates/mcp/src/tools/dispatch.rs | 6 +- crates/mcp/src/tools/handlers.rs | 31 +- crates/metafile/src/import.rs | 25 + crates/metafile/src/scene.rs | 3 +- crates/metafile/src/wmf.rs | 2 +- .../proptest-regressions/planar/tests.txt | 7 + crates/pathops/src/boolean.rs | 95 +- crates/pathops/src/edit.rs | 35 +- crates/pathops/src/offset.rs | 143 +- crates/pathops/src/pathfinder.rs | 79 +- crates/pathops/src/planar/map.rs | 7 +- crates/pathops/src/planar/tests.rs | 61 + crates/pathops/tests/it/ops.rs | 15 + crates/pdf/Cargo.toml | 4 + crates/pdf/src/editing.rs | 47 +- crates/pdf/src/export/paint.rs | 42 +- crates/pdf/src/images.rs | 33 +- crates/pdf/src/import.rs | 4 +- crates/pdf/src/import/builder.rs | 4 +- crates/pdf/src/import/device.rs | 5 +- crates/pdf/src/import/entry.rs | 52 +- crates/pdf/src/import/state.rs | 5 +- crates/pdf/src/lib.rs | 18 +- crates/pdf/src/pages.rs | 108 +- crates/pdf/src/piece_info.rs | 158 ++ crates/pdf/src/post.rs | 10 +- crates/pdf/src/print/layout.rs | 11 + crates/pdf/src/print/plates.rs | 37 +- crates/pdf/src/streams.rs | 18 +- crates/pdf/src/tests/colorize.rs | 12 + crates/pdf/src/tests/compression.rs | 29 + crates/pdf/src/tests/editing.rs | 47 +- crates/pdf/src/tests/import_layers.rs | 12 + crates/pdf/src/tests/output.rs | 64 + crates/pdf/src/tests/print.rs | 8 + crates/plugins/src/effect.rs | 13 +- crates/plugins/src/manifest.rs | 97 + crates/plugins/src/objects.rs | 53 +- crates/plugins/src/objects/tests.rs | 40 + crates/render/src/blobs.rs | 13 +- crates/render/src/brush_fx.rs | 10 + crates/render/src/encode/tga.rs | 3 +- crates/render/src/frame.rs | 17 +- crates/render/src/fx.rs | 2 +- crates/render/src/fx/bounds.rs | 17 + crates/render/src/fx/pixels.rs | 12 +- crates/render/src/fx/renderer.rs | 16 + crates/render/src/kept_paths.rs | 326 +++ crates/render/src/lib.rs | 4 + crates/render/src/options.rs | 4 + crates/render/src/paint.rs | 45 +- crates/render/src/placed_document.rs | 79 +- crates/render/src/renderer.rs | 25 +- crates/render/src/renderer/nodes.rs | 79 +- crates/render/src/renderer/paint.rs | 148 +- crates/render/src/tests.rs | 175 +- crates/render/src/tests/keptpath.rs | 210 ++ crates/render/src/tests/longstroke.rs | 335 +++ crates/render/src/tests/memory.rs | 44 + crates/render/src/tests/objectfx.rs | 14 + crates/render/src/tests/placed_document.rs | 47 +- crates/render/src/threads.rs | 1 + crates/scriptcheck/src/report.rs | 26 +- crates/svg/Cargo.toml | 1 + crates/svg/src/css.rs | 70 +- crates/svg/src/entities.rs | 52 +- crates/svg/src/export/nodes.rs | 7 +- crates/svg/src/export/output.rs | 6 + crates/svg/src/export/text.rs | 13 +- crates/svg/src/glyph.rs | 25 +- crates/svg/src/import.rs | 16 +- crates/svg/src/import/convert.rs | 69 +- crates/svg/src/import/importer.rs | 20 +- crates/svg/src/import/prepass.rs | 103 +- crates/svg/src/import/prepass/id_styles.rs | 124 + crates/svg/src/import/text.rs | 33 +- crates/svg/tests/it/basic_shapes.rs | 101 + crates/svg/tests/it/data_attributes.rs | 14 + crates/svg/tests/it/import_live.rs | 69 + crates/svg/tests/it/import_text.rs | 40 + crates/svg/tests/it/prop_svg.rs | 28 + crates/svg/tests/it/svg_glyphs.rs | 22 +- crates/svg/tests/it/text_layout.rs | 18 + crates/testkit/src/lib.rs | 18 +- crates/text/build.rs | 5 + crates/text/build/web_fonts.rs | 4 +- crates/text/src/edit.rs | 46 +- crates/text/src/features.rs | 5 +- crates/text/src/fontdb.rs | 4 + crates/text/src/fontdb/alternates.rs | 165 ++ crates/text/src/fontdb/build.rs | 7 +- crates/text/src/fontdb/db.rs | 50 + crates/text/src/fontdb/face.rs | 139 ++ crates/text/src/fontdb/names.rs | 30 +- crates/text/src/fontdb/stylistic_sets.rs | 224 ++ crates/text/src/kashida.rs | 4 +- crates/text/src/layout.rs | 131 +- crates/text/src/layout/driver.rs | 2 + crates/text/src/layout/flow.rs | 8 +- crates/text/src/layout/ruby.rs | 3 + crates/text/src/layout/vertical.rs | 34 +- crates/text/src/layout/wrapping_tests.rs | 50 + crates/text/src/lib.rs | 148 +- crates/text/src/shape.rs | 266 ++- crates/text/src/test_fonts.rs | 441 +++- crates/text/src/tests.rs | 15 + crates/text/src/tests/alternates.rs | 191 ++ crates/text/src/tests/bidi.rs | 3 + crates/text/src/tests/inline.rs | 50 + crates/text/src/tests/justify.rs | 26 +- crates/text/src/tests/ligature_carets.rs | 727 ++++++ crates/text/src/tests/sysfonts.rs | 30 + crates/text/src/tests/typo.rs | 104 + crates/text/src/tests/vertical.rs | 35 + crates/three-d/src/solid/inflate.rs | 8 +- crates/three-d/src/solid/rings.rs | 7 +- crates/three-d/src/solid/tests.rs | 22 + crates/tools/src/addons.rs | 11 +- crates/tools/src/addons/enclosing_lasso.rs | 183 ++ .../src/addons/enclosing_lasso/region.rs | 93 + .../tools/src/addons/enclosing_lasso/tests.rs | 369 +++ crates/tools/src/addons/measure_inspect.rs | 26 +- crates/tools/src/addons/smart_pen.rs | 9 + crates/tools/src/builder/map.rs | 11 +- crates/tools/src/builder/tests.rs | 7 + crates/tools/src/context.rs | 3 + crates/tools/src/cropimage.rs | 31 + crates/tools/src/cursors.toml | 2 +- crates/tools/src/dimension.rs | 100 +- crates/tools/src/direct.rs | 50 +- crates/tools/src/direct/tests.rs | 127 ++ crates/tools/src/distort/liquify.rs | 70 +- crates/tools/src/distort/width.rs | 42 + crates/tools/src/draw2/curvature.rs | 18 +- crates/tools/src/draw2/curvature/tests.rs | 28 + crates/tools/src/lib.rs | 11 +- crates/tools/src/meshblend.rs | 134 +- crates/tools/src/meshedit.rs | 21 + crates/tools/src/nineslice.rs | 229 ++ crates/tools/src/objects_on_path.rs | 519 ++++- crates/tools/src/pathtype.rs | 185 +- crates/tools/src/pen.rs | 9 + crates/tools/src/select.rs | 211 +- crates/tools/src/select/tests.rs | 82 + crates/tools/src/symbolism.rs | 84 +- crates/tools/src/text.rs | 71 +- crates/tools/src/text/ports.rs | 18 +- crates/tools/src/text/tests.rs | 93 + crates/tools/src/text/tool.rs | 67 +- crates/tools/src/touchtype.rs | 14 +- crates/tools/src/touchtype/tests.rs | 74 + crates/tools/src/xform.rs | 1 + crates/tools/src/xform/freeform.rs | 35 +- crates/tools/src/xform/gradient.rs | 11 + crates/tools/src/xform/measure.rs | 161 +- crates/tools/src/xform/wand.rs | 71 +- crates/trace/src/layers.rs | 84 +- crates/trace/src/logo/geom.rs | 65 +- crates/trace/src/mosaic.rs | 10 +- crates/trace/src/quantize.rs | 7 +- crates/trace/src/quantize/denoise.rs | 300 ++- crates/trace/src/quantize/palette.rs | 10 +- crates/trace/src/smooth.rs | 163 +- crates/trace/src/tests.rs | 49 + crates/ui-egui/src/addons.rs | 9 +- crates/ui-egui/src/addons/document.rs | 33 +- crates/ui-egui/src/addons/document/tests.rs | 26 + crates/ui-egui/src/addons/enclosing_lasso.rs | 8 + crates/ui-egui/src/addons/select.rs | 200 ++ crates/ui-egui/src/addons/select/tests.rs | 107 + crates/ui-egui/src/addons/typetools.rs | 96 + crates/ui-egui/src/addons/typetools/tests.rs | 62 + crates/ui-egui/src/app.rs | 34 +- crates/ui-egui/src/app/commands.rs | 24 +- crates/ui-egui/src/app/frame.rs | 2 +- crates/ui-egui/src/app/input.rs | 6 + crates/ui-egui/src/app/view.rs | 6 +- crates/ui-egui/src/caches.rs | 2 + crates/ui-egui/src/canvas.rs | 51 +- crates/ui-egui/src/canvas/drag.rs | 93 +- crates/ui-egui/src/canvas/fill.rs | 10 +- crates/ui-egui/src/canvas/home.rs | 2 +- crates/ui-egui/src/canvas/input.rs | 57 +- crates/ui-egui/src/canvas/overlays.rs | 27 +- crates/ui-egui/src/canvas/panes.rs | 235 +- crates/ui-egui/src/canvas/panes/tests.rs | 22 +- crates/ui-egui/src/canvas/task_bar.rs | 9 +- crates/ui-egui/src/canvas/tests.rs | 346 ++- crates/ui-egui/src/canvas/tool_overlays.rs | 17 + crates/ui-egui/src/chrome.rs | 2 +- crates/ui-egui/src/chrome/control_bar.rs | 67 +- crates/ui-egui/src/chrome/tabs.rs | 163 +- crates/ui-egui/src/chrome/tests.rs | 133 +- crates/ui-egui/src/control.rs | 24 +- crates/ui-egui/src/control/tests.rs | 17 + crates/ui-egui/src/credits.rs | 342 ++- crates/ui-egui/src/custom_toolbars.rs | 517 ++++- crates/ui-egui/src/dialogs.rs | 35 +- crates/ui-egui/src/dialogs/about.rs | 4 +- crates/ui-egui/src/dialogs/action_batch.rs | 8 +- crates/ui-egui/src/dialogs/action_new.rs | 120 + crates/ui-egui/src/dialogs/action_playback.rs | 48 + .../ui-egui/src/dialogs/action_set_options.rs | 34 +- .../ui-egui/src/dialogs/char_style_options.rs | 736 +++++- crates/ui-egui/src/dialogs/color_picker.rs | 18 +- crates/ui-egui/src/dialogs/command.rs | 13 +- crates/ui-egui/src/dialogs/effect.rs | 49 +- crates/ui-egui/src/dialogs/expand.rs | 36 +- .../ui-egui/src/dialogs/find_links/tests.rs | 3 + crates/ui-egui/src/dialogs/form.rs | 111 +- crates/ui-egui/src/dialogs/graph_data.rs | 19 +- crates/ui-egui/src/dialogs/import_pdf.rs | 8 +- crates/ui-egui/src/dialogs/justification.rs | 6 +- crates/ui-egui/src/dialogs/layer_options.rs | 337 ++- .../ui-egui/src/dialogs/live_paint_bucket.rs | 27 +- crates/ui-egui/src/dialogs/live_paint_gaps.rs | 64 +- .../src/dialogs/live_paint_selection.rs | 27 +- .../src/dialogs/missing_fonts/tests.rs | 24 +- crates/ui-egui/src/dialogs/missing_links.rs | 5 + .../src/dialogs/navigator_panel_options.rs | 101 + crates/ui-egui/src/dialogs/new_document.rs | 56 +- crates/ui-egui/src/dialogs/new_text_style.rs | 8 + crates/ui-egui/src/dialogs/package.rs | 69 +- crates/ui-egui/src/dialogs/recolor.rs | 116 +- crates/ui-egui/src/dialogs/save_for_web.rs | 10 +- crates/ui-egui/src/dialogs/save_options.rs | 8 +- .../ui-egui/src/dialogs/save_options/tests.rs | 12 + .../src/dialogs/shape_builder_options.rs | 26 +- crates/ui-egui/src/dialogs/tate_chu_yoko.rs | 45 +- crates/ui-egui/src/dialogs/tests.rs | 558 ++++- crates/ui-egui/src/dialogs/tests_export.rs | 31 + crates/ui-egui/src/dialogs/tests_package.rs | 93 +- .../ui-egui/src/dialogs/tests_save_for_web.rs | 15 + crates/ui-egui/src/dialogs/tile_edge_color.rs | 51 +- crates/ui-egui/src/dialogs/transform.rs | 12 +- crates/ui-egui/src/dialogs/transform_each.rs | 465 +++- crates/ui-egui/src/doc_windows.rs | 705 ++++++ crates/ui-egui/src/doc_windows/tests.rs | 892 ++++++++ crates/ui-egui/src/doc_windows/views.rs | 247 ++ crates/ui-egui/src/document_drag.rs | 14 +- crates/ui-egui/src/edit_toolbar.rs | 16 +- crates/ui-egui/src/edit_toolbar/tests.rs | 19 +- crates/ui-egui/src/find_font.rs | 758 +++++- crates/ui-egui/src/font_menu.rs | 471 +++- crates/ui-egui/src/font_menu/tests.rs | 130 ++ crates/ui-egui/src/i18n/bg.tsv | 95 + crates/ui-egui/src/i18n/bs.tsv | 99 +- crates/ui-egui/src/i18n/ca.tsv | 105 +- crates/ui-egui/src/i18n/catalog.rs | 27 +- crates/ui-egui/src/i18n/cs.tsv | 107 +- crates/ui-egui/src/i18n/da.tsv | 95 + crates/ui-egui/src/i18n/de.tsv | 105 +- crates/ui-egui/src/i18n/el.tsv | 105 +- crates/ui-egui/src/i18n/es.tsv | 105 +- crates/ui-egui/src/i18n/fi.tsv | 110 +- crates/ui-egui/src/i18n/fr.tsv | 105 +- crates/ui-egui/src/i18n/hu.tsv | 105 +- crates/ui-egui/src/i18n/id.tsv | 95 + crates/ui-egui/src/i18n/it.tsv | 105 +- crates/ui-egui/src/i18n/ja.tsv | 105 +- crates/ui-egui/src/i18n/ka.tsv | 128 +- crates/ui-egui/src/i18n/ko.tsv | 105 +- crates/ui-egui/src/i18n/nl.tsv | 95 + crates/ui-egui/src/i18n/pl.tsv | 105 +- crates/ui-egui/src/i18n/pt-br.tsv | 105 +- crates/ui-egui/src/i18n/ru.tsv | 217 +- crates/ui-egui/src/i18n/sv.tsv | 109 +- crates/ui-egui/src/i18n/tests.rs | 82 +- crates/ui-egui/src/i18n/tr.tsv | 105 +- crates/ui-egui/src/i18n/uk.tsv | 105 +- crates/ui-egui/src/i18n/vi.tsv | 105 +- crates/ui-egui/src/i18n/zh-hans.tsv | 107 +- crates/ui-egui/src/i18n/zh-hant.tsv | 105 +- crates/ui-egui/src/io.rs | 6 +- crates/ui-egui/src/io/export.rs | 16 + crates/ui-egui/src/io/open.rs | 35 +- crates/ui-egui/src/io/save.rs | 3 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus/invoke.rs | 2 + crates/ui-egui/src/menus/run.rs | 43 +- crates/ui-egui/src/menus/state.rs | 16 +- crates/ui-egui/src/menus/submenus.rs | 22 +- crates/ui-egui/src/menus/tests.rs | 36 +- crates/ui-egui/src/menus/tree.rs | 6 +- crates/ui-egui/src/menus/ui_commands.rs | 121 +- crates/ui-egui/src/native_menu.rs | 5 +- crates/ui-egui/src/opening.rs | 36 +- crates/ui-egui/src/opening/tests.rs | 203 +- crates/ui-egui/src/panel_docking.rs | 25 +- .../ui-egui/src/panel_docking/native_tests.rs | 293 ++- crates/ui-egui/src/panel_docking/tests.rs | 13 + crates/ui-egui/src/panels/actions.rs | 2031 ++++++++++++++++- crates/ui-egui/src/panels/actions/files.rs | 49 +- crates/ui-egui/src/panels/appearance.rs | 7 + crates/ui-egui/src/panels/character.rs | 146 +- crates/ui-egui/src/panels/character/tests.rs | 116 + crates/ui-egui/src/panels/color.rs | 45 +- crates/ui-egui/src/panels/color/tests.rs | 6 +- crates/ui-egui/src/panels/glyphs.rs | 352 ++- crates/ui-egui/src/panels/layers/thumbs.rs | 244 +- crates/ui-egui/src/panels/links.rs | 2 +- crates/ui-egui/src/panels/navigator.rs | 225 +- crates/ui-egui/src/panels/opentype.rs | 135 +- crates/ui-egui/src/panels/paragraph.rs | 42 +- crates/ui-egui/src/panels/pathfinder.rs | 4 +- crates/ui-egui/src/panels/properties.rs | 29 +- crates/ui-egui/src/panels/properties/tests.rs | 102 + .../src/panels/properties/vertical_align.rs | 65 + crates/ui-egui/src/panels/tabs.rs | 7 +- crates/ui-egui/src/panels/tabs/tests.rs | 34 + crates/ui-egui/src/panels/tests_appearance.rs | 468 ++++ crates/ui-egui/src/panels/tests_constrain.rs | 16 + crates/ui-egui/src/panels/tests_effectedit.rs | 8 +- crates/ui-egui/src/panels/tests_links.rs | 40 +- crates/ui-egui/src/panels/tests_units.rs | 86 +- crates/ui-egui/src/panels/text_styles.rs | 16 +- crates/ui-egui/src/panels/transform.rs | 6 +- crates/ui-egui/src/panels/variables.rs | 17 +- crates/ui-egui/src/picks.rs | 58 +- crates/ui-egui/src/place.rs | 17 +- crates/ui-egui/src/placing.rs | 8 +- crates/ui-egui/src/placing/tests.rs | 39 + crates/ui-egui/src/prefs_dialog.rs | 65 +- crates/ui-egui/src/prefs_dialog/tests.rs | 155 ++ crates/ui-egui/src/render_worker.rs | 6 +- crates/ui-egui/src/services.rs | 7 + crates/ui-egui/src/shortcut_editor.rs | 59 +- crates/ui-egui/src/shortcuts.rs | 49 +- crates/ui-egui/src/shortcuts/tests.rs | 55 + crates/ui-egui/src/state.rs | 29 + crates/ui-egui/src/sysclip.rs | 115 +- crates/ui-egui/src/tests.rs | 1 + crates/ui-egui/src/tests/clipboard.rs | 233 ++ crates/ui-egui/src/tests/document_drag.rs | 35 + crates/ui-egui/src/tests/font_menu.rs | 330 +++ crates/ui-egui/src/tests/fonts.rs | 24 +- crates/ui-egui/src/tests/home.rs | 132 +- crates/ui-egui/src/tests/nativemenu.rs | 55 +- crates/ui-egui/src/tests/pathtype.rs | 97 +- crates/ui-egui/src/tests/picks.rs | 25 +- crates/ui-egui/src/tests/place.rs | 22 +- crates/ui-egui/src/tests/recolor.rs | 233 ++ crates/ui-egui/src/tests/ruler_origin.rs | 2 +- crates/ui-egui/src/tests/save.rs | 2 + crates/ui-egui/src/tests/screenmode.rs | 72 + crates/ui-egui/src/tests/sysclip.rs | 51 +- crates/ui-egui/src/tests/sysclip_guides.rs | 102 + crates/ui-egui/src/tests/taskbar.rs | 23 +- crates/ui-egui/src/thumb_worker.rs | 8 +- crates/ui-egui/src/toolbar.rs | 2 +- crates/ui-egui/src/toolbar/tests.rs | 11 + crates/ui-egui/src/unsaved.rs | 2 +- crates/ui-egui/src/widgets.rs | 54 +- crates/ui-egui/src/widgets/color.rs | 96 +- crates/ui-egui/src/widgets/dropdowns.rs | 23 +- crates/ui-egui/src/widgets/look_tests.rs | 30 + crates/ui-egui/src/widgets/numeric.rs | 162 +- .../ui-egui/tests/color_working_components.rs | 188 ++ crates/ui-egui/tests/font_wheel.rs | 162 ++ docs/control-protocol.md | 37 +- docs/file-format-parity.md | 31 +- docs/gaps.md | 63 +- docs/mcp.md | 500 +++- docs/pdf-backend.md | 4 +- docs/plugins.md | 23 +- docs/roadmap.md | 46 +- docs/target-app-parity.md | 206 +- docs/type-parity.md | 27 +- docs/ui-parity.md | 63 +- xtask/src/pdf_deps.rs | 10 +- xtask/src/version.rs | 12 +- xtask/src/web_fonts.rs | 54 +- 672 files changed, 56229 insertions(+), 4037 deletions(-) create mode 100644 crates/doc/src/twist.rs create mode 100644 crates/doc/src/twist/tests.rs create mode 100644 crates/engine/examples/anchor_bench.rs create mode 100644 crates/engine/src/cmd/addons/document/batch_trace.rs create mode 100644 crates/engine/src/cmd/addons/select.rs create mode 100644 crates/engine/src/cmd/addons/select/tests.rs create mode 100644 crates/engine/src/cmd/addons/typetools.rs create mode 100644 crates/engine/src/cmd/addons/typetools/tests.rs create mode 100644 crates/engine/src/tests/ai_open_path.rs create mode 100644 crates/engine/src/tests/expand_symbols.rs create mode 100644 crates/engine/src/tests/guide_clipboard.rs create mode 100644 crates/eps/src/ai_editing.rs create mode 100644 crates/eps/src/ai_editing/tests.rs create mode 100644 crates/eps/src/ai_editing/writer.rs create mode 100644 crates/eps/src/ai_ops.rs create mode 100644 crates/eps/src/import/ai/brush.rs create mode 100644 crates/pathops/proptest-regressions/planar/tests.txt create mode 100644 crates/pdf/src/piece_info.rs create mode 100644 crates/render/src/kept_paths.rs create mode 100644 crates/render/src/tests/keptpath.rs create mode 100644 crates/render/src/tests/longstroke.rs create mode 100644 crates/svg/src/import/prepass/id_styles.rs create mode 100644 crates/text/src/fontdb/alternates.rs create mode 100644 crates/text/src/fontdb/stylistic_sets.rs create mode 100644 crates/text/src/tests/alternates.rs create mode 100644 crates/text/src/tests/ligature_carets.rs create mode 100644 crates/tools/src/addons/enclosing_lasso.rs create mode 100644 crates/tools/src/addons/enclosing_lasso/region.rs create mode 100644 crates/tools/src/addons/enclosing_lasso/tests.rs create mode 100644 crates/tools/src/nineslice.rs create mode 100644 crates/ui-egui/src/addons/enclosing_lasso.rs create mode 100644 crates/ui-egui/src/addons/select.rs create mode 100644 crates/ui-egui/src/addons/select/tests.rs create mode 100644 crates/ui-egui/src/addons/typetools.rs create mode 100644 crates/ui-egui/src/addons/typetools/tests.rs create mode 100644 crates/ui-egui/src/dialogs/action_new.rs create mode 100644 crates/ui-egui/src/dialogs/action_playback.rs create mode 100644 crates/ui-egui/src/dialogs/navigator_panel_options.rs create mode 100644 crates/ui-egui/src/doc_windows.rs create mode 100644 crates/ui-egui/src/doc_windows/tests.rs create mode 100644 crates/ui-egui/src/doc_windows/views.rs create mode 100644 crates/ui-egui/src/panels/properties/vertical_align.rs create mode 100644 crates/ui-egui/src/tests/sysclip_guides.rs create mode 100644 crates/ui-egui/tests/color_working_components.rs create mode 100644 crates/ui-egui/tests/font_wheel.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 07b3c6b7f..bbb0b8d79 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,6 +82,7 @@ jobs: WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} + LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} diff --git a/ASSETS.md b/ASSETS.md index db2a97816..e4a17c764 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -358,20 +358,20 @@ The shared PDF backend keeps the existing standard-font and CMap features. These | Crate / file | Author | Source | Licence | SHA-256 | Use | | --- | --- | --- | --- | --- | --- | -| `hayro-interpret 0.7.0 / assets/FoxitDingbats.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitDingbats.pfb | BSD-3-Clause | `845c752392b6c914fb989c75a08b7792b88f542d2499042ef2889f8c814a16ed` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/FoxitFixed.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitFixed.pfb | BSD-3-Clause | `b6c8fe53f134b8b6d4578cd2d544df4cee9624c4efa8d51a560fb40ea296101b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/FoxitFixedBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitFixedBold.pfb | BSD-3-Clause | `f1b7159702973f54fc86254ea38bcf3712b2a736eb3a0995751e9f4bb45ad603` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/FoxitFixedBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitFixedBoldItalic.pfb | BSD-3-Clause | `8a000945843bd31add06aee63bf9fd41b7578b4f3242a4b7cd46349ad9d24d4d` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/FoxitFixedItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitFixedItalic.pfb | BSD-3-Clause | `5007faf8320fa1fcc08b8894b356ad976f60b992ba29ee5272578bbdebaf3876` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/FoxitSans.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSans.pfb | BSD-3-Clause | `a02339e95d20db14cbdfc1c6baf359cd36a091d5434925c3a4040f60e1a1af9e` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/FoxitSansBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSansBold.pfb | BSD-3-Clause | `7bdfa36de1d5757dcfaf8b8a4be95c6e41c40fa70dbed9d78bf979a7b9959054` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/FoxitSansBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSansBoldItalic.pfb | BSD-3-Clause | `555ce45924338e2e4b32821910221ce85da691fd59a9723c4c971a7c5627fc8b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/FoxitSansItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSansItalic.pfb | BSD-3-Clause | `7751cbefeda351708dae8c4a88db043411bc39ab2ae2097685da845d4d16f307` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/FoxitSerif.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSerif.pfb | BSD-3-Clause | `4f57d2b9d884af8f907bf22df6019b52d86cbf6214fdbd02b1ae05472a543f35` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/FoxitSerifBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSerifBold.pfb | BSD-3-Clause | `0bdf4b04e964139818d51eda03d566ba999fc3ba2421b1c6c51f9dc969022e80` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/FoxitSerifBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSerifBoldItalic.pfb | BSD-3-Clause | `a406cac82583bf98175cb62c87ed5e95c45fbb34eece63a10b0a13e793cb2e10` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/FoxitSerifItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSerifItalic.pfb | BSD-3-Clause | `610ae0687198045c4db5d1a6650fd5e92536631706382eb8b72e38df578d0ae9` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/FoxitSymbol.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSymbol.pfb | BSD-3-Clause | `47967d055530e7357088a08403115425643ec2cdfd6201ba8af0fbd7116c1539` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | -| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc | CC0-1.0 | `73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0` | Default CMYK colour conversion (pinned hayro-interpret) | -| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-cmap/assets/cmaps.brotli | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual dependency data; existing embedding unchanged | -| `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual dependency data; existing embedding unchanged | +| `hayro-interpret 0.7.0 / assets/FoxitDingbats.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/vendor/hayro-interpret/assets/FoxitDingbats.pfb | BSD-3-Clause | `845c752392b6c914fb989c75a08b7792b88f542d2499042ef2889f8c814a16ed` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitFixed.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/vendor/hayro-interpret/assets/FoxitFixed.pfb | BSD-3-Clause | `b6c8fe53f134b8b6d4578cd2d544df4cee9624c4efa8d51a560fb40ea296101b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitFixedBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/vendor/hayro-interpret/assets/FoxitFixedBold.pfb | BSD-3-Clause | `f1b7159702973f54fc86254ea38bcf3712b2a736eb3a0995751e9f4bb45ad603` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitFixedBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/vendor/hayro-interpret/assets/FoxitFixedBoldItalic.pfb | BSD-3-Clause | `8a000945843bd31add06aee63bf9fd41b7578b4f3242a4b7cd46349ad9d24d4d` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitFixedItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/vendor/hayro-interpret/assets/FoxitFixedItalic.pfb | BSD-3-Clause | `5007faf8320fa1fcc08b8894b356ad976f60b992ba29ee5272578bbdebaf3876` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSans.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/vendor/hayro-interpret/assets/FoxitSans.pfb | BSD-3-Clause | `a02339e95d20db14cbdfc1c6baf359cd36a091d5434925c3a4040f60e1a1af9e` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSansBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/vendor/hayro-interpret/assets/FoxitSansBold.pfb | BSD-3-Clause | `7bdfa36de1d5757dcfaf8b8a4be95c6e41c40fa70dbed9d78bf979a7b9959054` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSansBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/vendor/hayro-interpret/assets/FoxitSansBoldItalic.pfb | BSD-3-Clause | `555ce45924338e2e4b32821910221ce85da691fd59a9723c4c971a7c5627fc8b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSansItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/vendor/hayro-interpret/assets/FoxitSansItalic.pfb | BSD-3-Clause | `7751cbefeda351708dae8c4a88db043411bc39ab2ae2097685da845d4d16f307` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSerif.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/vendor/hayro-interpret/assets/FoxitSerif.pfb | BSD-3-Clause | `4f57d2b9d884af8f907bf22df6019b52d86cbf6214fdbd02b1ae05472a543f35` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSerifBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/vendor/hayro-interpret/assets/FoxitSerifBold.pfb | BSD-3-Clause | `0bdf4b04e964139818d51eda03d566ba999fc3ba2421b1c6c51f9dc969022e80` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSerifBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/vendor/hayro-interpret/assets/FoxitSerifBoldItalic.pfb | BSD-3-Clause | `a406cac82583bf98175cb62c87ed5e95c45fbb34eece63a10b0a13e793cb2e10` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSerifItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/vendor/hayro-interpret/assets/FoxitSerifItalic.pfb | BSD-3-Clause | `610ae0687198045c4db5d1a6650fd5e92536631706382eb8b72e38df578d0ae9` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSymbol.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/vendor/hayro-interpret/assets/FoxitSymbol.pfb | BSD-3-Clause | `47967d055530e7357088a08403115425643ec2cdfd6201ba8af0fbd7116c1539` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | https://github.com/storytold/craft-libs/blob/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc | CC0-1.0 | `73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0` | Default CMYK colour conversion (pinned hayro-interpret) | +| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://github.com/storytold/craft-libs/blob/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/vendor/hayro-cmap/assets/cmaps.brotli | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual dependency data; existing embedding unchanged | +| `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/craft-libs/blob/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual dependency data; existing embedding unchanged | diff --git a/Cargo.lock b/Cargo.lock index d7731fbd9..0d3c1f19e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -970,17 +970,17 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" [[package]] name = "craft-fonts-build" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1003,7 +1003,7 @@ dependencies = [ [[package]] name = "craft-keymap" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" dependencies = [ "egui", "serde", @@ -1013,7 +1013,7 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" dependencies = [ "hayro-interpret", "hayro-syntax", @@ -1024,9 +1024,10 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" dependencies = [ "craft-winprint-gdi", + "log", "miniz_oxide 0.9.1", "roxmltree 0.21.2", "tempfile", @@ -1037,7 +1038,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -1084,7 +1085,7 @@ dependencies = [ [[package]] name = "craft-vectorcraft-format" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=14d5dbb50c317b8747a9cf9eb35ab8a22515f137#14d5dbb50c317b8747a9cf9eb35ab8a22515f137" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" dependencies = [ "flate2", "serde", @@ -1095,7 +1096,7 @@ dependencies = [ [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" dependencies = [ "egui", "objc2 0.6.5", @@ -2205,7 +2206,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-cmap" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" dependencies = [ "brotli", "hayro-postscript", @@ -2214,7 +2215,7 @@ dependencies = [ [[package]] name = "hayro-interpret" version = "0.7.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" dependencies = [ "bitflags 2.13.2", "hayro-cmap", @@ -2232,7 +2233,7 @@ dependencies = [ [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" dependencies = [ "fearless_simd", "hayro-ccitt", @@ -2250,12 +2251,12 @@ dependencies = [ [[package]] name = "hayro-postscript" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" dependencies = [ "flate2", "hayro-ccitt", @@ -2863,7 +2864,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=fb08be5fa19eee8d37e624397fcdc96df6035e92#fb08be5fa19eee8d37e624397fcdc96df6035e92" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" dependencies = [ "base64 0.22.1", "bumpalo", @@ -6283,6 +6284,7 @@ dependencies = [ "vectorcraft-effects", "vectorcraft-geom", "vectorcraft-metafile", + "vectorcraft-pathops", "vectorcraft-pdf", "vectorcraft-render", "vectorcraft-testkit", @@ -6457,6 +6459,7 @@ dependencies = [ "resvg", "serde", "serde_json", + "simplecss", "skrifa 0.47.0", "svgtypes 0.16.1", "thiserror 2.0.21", @@ -6753,12 +6756,12 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.262.0" +version = "0.263.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +checksum = "0b883a26aea02eaabe2d2c3fba7eb039ef5cdcb5d5201ae5551e73d5e251a81f" dependencies = [ "leb128fmt", - "wasmparser 0.262.0", + "wasmparser 0.263.0", ] [[package]] @@ -6813,9 +6816,9 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.262.0" +version = "0.263.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +checksum = "4f5fa0a054ae153606df820eb0a84213e28cda41bbe04ba2628e71b6ba100fcb" dependencies = [ "bitflags 2.13.2", "indexmap", @@ -6824,9 +6827,9 @@ dependencies = [ [[package]] name = "wast" -version = "262.0.0" +version = "263.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30612a0c29249b5f185888a107e8a519d920226b7d07f9d351fd094e9f75eb23" +checksum = "69d90bcf581b501aa1c358bc5dc0faa979cd375796908860ebcb6d1d70b907c7" dependencies = [ "bumpalo", "leb128fmt", @@ -6837,9 +6840,9 @@ dependencies = [ [[package]] name = "wat" -version = "1.262.0" +version = "1.263.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228027f2b07bbaf946980ebbe2437fca73be6f54c40c2a8d4f42f277075f8d1" +checksum = "bbc9c5aca783b3f1838be89f3f9937c528b90ec5faa6fb62d809726c16a7d8f7" dependencies = [ "wast", ] diff --git a/Cargo.toml b/Cargo.toml index 5436d6d72..8814a14c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,16 +11,16 @@ rust-version = "1.95" repository = "https://github.com/storytold/vectorcraft" [workspace.dependencies] -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false, features = ["interpret", "write"] } -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] } -craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", default-features = false, features = ["interpret", "write"] } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", features = ["serde"] } +craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } # Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per # desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.) -craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", features = ["egui"] } # The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON. -craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "14d5dbb50c317b8747a9cf9eb35ab8a22515f137" } +craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } # craft-update: app-specific pretend versions apply to startup and manual checks. craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } # internal @@ -147,14 +147,12 @@ lto = "thin" codegen-units = 1 [patch.crates-io] -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -# The PDF writer takes craft-libs#417 (CMYK, gray and spot colors keep their exact inks); the other -# craft-libs crates stay at 92d392cf. craft-pdf's sources are the same at both revisions. -krilla = { git = "https://github.com/storytold/craft-libs", rev = "fb08be5fa19eee8d37e624397fcdc96df6035e92" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } +hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } +hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } +hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } diff --git a/NOTICE b/NOTICE index 0d0758820..4bf18085b 100644 --- a/NOTICE +++ b/NOTICE @@ -72,10 +72,10 @@ Shared PDF backend The patched hayro-interpret 0.7.0, hayro-syntax 0.7.2, hayro-jbig2 0.3.0, hayro-cmap 0.1.0 and hayro-postscript 0.1.0 are fetched from https://github.com/storytold/craft-libs at revision -92d392cff132eaf53e13afff77f38b5c11b73aa0. Their upstream project is +9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb. Their upstream project is https://github.com/LaurenzV/hayro, licensed MIT OR Apache-2.0. The snapshot retains each crate's upstream licence files. The patch ledger is at -https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/README.craft-reader.md. +https://github.com/storytold/craft-libs/blob/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/vendor/README.craft-reader.md. Assets already bundled through Hayro's default features retain their licences: - Foxit standard-14 substitutes: Copyright 2014 PDFium Authors. All rights reserved. diff --git a/ROADMAP.md b/ROADMAP.md index 8ebfd6522..16bd8b1da 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # VectorCraft Roadmap +- Paint: new gradients use the document's color model for default stops, including Gradient tool creation. Scores and estimates unchanged. + - PDF.R1: editable PDF import uses the shared reader, scans page content in windows, and rejects known incomplete input before returning a document. - PDF.W1: PDF export and print composition use the shared optional writer for configuration, metadata, font preparation and guarded finalization; drawing and output policy remain app-owned. @@ -9,7 +11,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Progress log row for the Color Effects add-on's Find Nearest Swatch; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON @@ -129,6 +131,8 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log +- 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged. +- 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged. - 2026-10-31: Pattern libraries: Window › Swatch Libraries › Patterns and the Swatches panel's library menu open Geometric Patterns, a built-in library of eleven pattern swatches (dots, stripes, checks, a grid and shapes); clicking one adds its pattern and swatch to the document, once, and applies it to the active fill or stroke (`swatch.library.add`). - 2026-11-01: Find Nearest Swatch joins the Color Effects add-on (Object › Plug-ins › Color Effects): for each colour of the selection, the closest swatches of the document, a built-in library or a library or colour book the user loaded, with their CIEDE2000 difference; Apply gives each colour its nearest swatch as one undo step, linking global and spot swatches and adding a library's to the document first, so a process colour becomes the closest spot colour (`addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`, #4923). - 2026-11-01: Expand Dashes into Paths joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): each selected path's dashed stroke becomes separate open paths, one per dash, laid out exactly as the stroke draws them (pattern, offset, Align Dashes to Corners and Path Ends), each keeping the stroke with the dashes off, so the gaps are real gaps for cutters and plotters, as one undo step (`addon.pathtools.expandDashes`, #4922). @@ -137,6 +141,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap - 2026-11-01: New Symbols from Layers joins the Document Tools add-on (Object › Plug-ins › Document Tools): one symbol of each top-level layer's art (the layers highlighted in the Layers panel when two or more are, else every layer), named after the layer, made as New Symbol makes one; Convert Layers to Symbols also puts an instance of each in place of its layer's art. Empty layers are skipped and reported; one undo step. A VectorCraft addition: headline numbers unchanged. - 2026-11-01: Document Tools, a built-in add-on (Object › Plug-ins › Document Tools), starts with Save a Timestamped Copy: a copy of the document named `_YYYY-MM-DD_HH-MM` (UTC; `-2`, `-3`… when that name is taken) goes next to it, or into a folder picked for a document never saved, written as File › Save a Copy writes it, so the document keeps its path, title and modified state. Off on the web, which has no clock. A VectorCraft addition: headline numbers unchanged. - 2026-11-01: Divide into Dashes joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): the selected paths, or only their direct-selected segments, are cut into separate open paths of a dash length measured along the curve, with an optional gap (left empty, or kept as pieces), each keeping the path's appearance; Fit to Corners and Ends stretches or squeezes the lengths so every run starts and ends on a dash. A dialog with Preview, one undo step, at most 100000 pieces. A VectorCraft addition: headline numbers unchanged. +- 2026-11-03: Text Tweaker, the first tool of the Type Tools add-on (Object › Plug-ins › Type Tools): one character attribute (size, baseline shift, rotation, horizontal or vertical scale, tracking, leading or stroke weight) goes letter by letter from a start value to an end value along a straight ramp, an ease, a bow, a spike or at random with a seed, set as the Character panel sets it, while the text stays live; a dialog with Preview, one undo step (`addon.type.textTweaker`, #4924). A VectorCraft addition: headline numbers unchanged. - 2026-11-01: Group Overlapping Objects joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): the selected objects whose visual bounds overlap or touch, chains included, go into one group each, within their own layer or group, at the stacking place of their frontmost member; objects that overlap nothing stay as they are. One undo step, a sweep along x so thousands of loose pieces group quickly (at most 20000 objects). A VectorCraft addition: headline numbers unchanged. - 2026-10-31: Export for Screens works as the reference app's dialog: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File (the chosen artboards as one PDF's pages, `pdfs: "single"`) or Multiple Files, iOS and Android rows, format presets in a ☰ menu, and a count of the files the export writes (#4895). About 55 community PRs and fixes landed the same day: Find Font, Replace Font, Outline Stroke and Expand leave hidden and locked art alone (with a warning); DXF export writes LINE and ELLIPSE entities and outlines only the strokes a CAD line can't draw; Blob Brush Angle and Roundness; Shift-constrained Perspective Selection; Grid Repeat Options Preview; a gradient mesh from a file budgeted at about a million quads; width-profile strokes keep their shape zoomed out; `.ai`/EPS import fidelity (area type, envelopes, transparency in dictionaries, character styles); PDF import finds hyphenated and CMap-suffixed font names; Windows renders follow the process's core affinity; Enter or Escape in a floating panel's field no longer reaches the canvas (#4803); Properties shows Blend Options for any selected blend (#4817); Artwork Cleanup finds doubled type (#4835) and joins stacked lines into one object (Join Point Text, #4826). Headline numbers unchanged. - 2026-10-31: Arrange Tools, a built-in add-on (Object › Plug-ins › Arrange Tools): Arrange in Grid lays the selected objects out in rows and columns, a row or a column with gaps, from the selection's top-left or centred on the artboard; Swap Objects swaps two objects' places (and, with Swap Objects and Sizes, their sizes); Replace with Top Object puts a copy of the topmost selected object in place of each of the others, kept, stretched or fitted in proportion; Set Size of Each gives every selected object an exact width and/or height about a reference point; Zoom to Selection fits the view to the selection. Each is one undo step with a previewed dialog where it has options. A VectorCraft addition: headline numbers unchanged. @@ -214,6 +219,8 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Progress log row for the Type Tools add-on's Text Tweaker (#4924); headline numbers unchanged. | +| 2026-11-02 | minor | Closed font-family fields accept wheel input for selected text in Character, Properties and Control (#5253); scores and estimates unchanged. | | 2026-11-01 | minor | Progress log row for the Color Effects add-on's Find Nearest Swatch (#4923); headline numbers unchanged. | | 2026-11-01 | minor | Progress log row for the Path Precision Tools add-on's Expand Dashes into Paths (#4922); headline numbers unchanged. | | 2026-11-01 | minor | Progress log row for the Arrange Tools add-on's Randomize Opacity (#4925); headline numbers unchanged. | diff --git a/apps/vectorcraft-web/src/keys.rs b/apps/vectorcraft-web/src/keys.rs index 8cb27096b..e2f3ea0b4 100644 --- a/apps/vectorcraft-web/src/keys.rs +++ b/apps/vectorcraft-web/src/keys.rs @@ -27,6 +27,19 @@ pub(crate) fn kept_from_browser(key: &str, code: &str, modifiers: Modifiers, alt vectorcraft_ui_egui::shortcuts::runs_a_shortcut(key, code, modifiers) } +/// Leave a paste to its Paste event (#5555). eframe's web runner reports a Cmd+V press both as the +/// key press and, with the clipboard's text, as a Paste event (the browser's `paste` comes in the +/// same task as the `keydown`, so both are in one frame's input); the app pastes on each, as egui's +/// window integration sends only the Paste. With a Paste in `events`, the paste key's press is +/// dropped; its release stays, so the paste chord is still followed (`PasteChord`). A Cmd+V without +/// a Paste (a clipboard with no text) keeps its press. +pub(crate) fn drop_paste_key_presses(events: &mut Vec) { + if !events.iter().any(|e| matches!(e, egui::Event::Paste(_))) { + return; + } + events.retain(|e| !matches!(e, egui::Event::Key { key: Key::V, pressed: true, modifiers, .. } if modifiers.command)); +} + /// The modifiers of a key event as eframe reports them to egui (its `modifiers_from_kb_event`). #[cfg(target_arch = "wasm32")] fn modifiers(e: &web_sys::KeyboardEvent) -> Modifiers { @@ -115,4 +128,31 @@ mod tests { assert!(!kept_from_browser("ż", "KeyZ", ctrl_alt, true)); assert!(!kept_from_browser("}", "Digit0", ctrl_alt, true)); } + + fn key(key: Key, pressed: bool, modifiers: Modifiers) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers } + } + + #[test] + fn a_paste_chord_pastes_once() { + // Cmd+V and Cmd+Shift+V (Paste in Place): the press goes, the Paste and the release stay. + for mods in [cmd(), cmd() | Modifiers::SHIFT] { + let mut events = vec![key(Key::V, true, mods), egui::Event::Paste("".into()), key(Key::V, false, mods)]; + drop_paste_key_presses(&mut events); + assert_eq!(events, vec![egui::Event::Paste("".into()), key(Key::V, false, mods)], "{mods:?}"); + } + } + + #[test] + fn keys_without_a_paste_stay() { + // No text on the clipboard: no Paste event, so the press is the paste chord's only report. + let mut events = vec![key(Key::V, true, cmd()), key(Key::V, false, cmd())]; + drop_paste_key_presses(&mut events); + assert_eq!(events.len(), 2); + // Typing V, and other chords, beside a paste. + let typed = vec![key(Key::V, true, Modifiers::NONE), key(Key::C, true, cmd()), egui::Event::Paste("a".into())]; + let mut events = typed.clone(); + drop_paste_key_presses(&mut events); + assert_eq!(events, typed); + } } diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index 859d41ef7..75eb7b308 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -378,6 +378,8 @@ impl eframe::App for WebShell { /// shared input hook before egui runs. fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { raw.events.extend(self.host.pointers.borrow_mut().take()); + // A Cmd+V comes as its key and as a Paste: one paste (#5555). + crate::keys::drop_paste_key_presses(&mut raw.events); if let Some(app) = &mut self.app { app.raw_input_hook(ctx, raw); } diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 47d26eb7b..f46213231 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -56,8 +56,9 @@ pollster = "1" # `GetPointerPenInfo`, which it uses to read the pen's pressure and which end of the pen is down. # Win32_System_Threading has `GetProcessAffinityMask`, which src/windows_cores.rs uses to count the # cores the process may run on. Win32_UI_Input_KeyboardAndMouse has `GetKeyState`, which -# src/caps_lock.rs uses to read whether Caps Lock is on. -windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } +# src/caps_lock.rs uses to read whether Caps Lock is on. Win32_System_Registry has `RegGetValueW`, +# which src/host_services.rs uses to read the path PhotoCraft registers under App Paths. +windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } # The renderer's worker count follows those cores (src/windows_cores.rs). vectorcraft-render = { workspace = true } diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index cec7fca1f..1c0b4cd3c 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -157,6 +157,10 @@ fn copied_file(paths: Vec, mimes: &[&'static str]) -> Option { None if vectorcraft_engine::file_access::current().is_none() => {} None => return None, } + // A device or pipe may never end: only a regular file is read whole. + if !std::fs::metadata(&path).ok()?.is_file() { + return None; + } let mut head = Vec::with_capacity(FILE_HEAD); std::fs::File::open(&path).ok()?.take(FILE_HEAD as u64).read_to_end(&mut head).ok()?; let mime = file_flavour(&path.to_string_lossy(), &head, mimes)?; @@ -172,8 +176,7 @@ mod win { use clipboard_win::{formats, options::NoClear, raw}; use super::{ - BITMAP, ClipboardRead, Cursor, Flavour, PNG, SVG, SystemClipboard, TEXT, copied_file, decode_png, dib_image, png_flavour, text_of, - windows_formats, + BITMAP, ClipboardRead, Cursor, Flavour, PNG, SVG, SystemClipboard, TEXT, copied_file, decode_png, dib_image, png_flavour, windows_formats, }; /// The device-independent bitmaps, the one that carries alpha first. Each is there whenever the @@ -207,8 +210,6 @@ mod win { pub struct Clipboard { /// The clipboard's sequence number right after our last write. seq: Option, - /// The text of our last write. - text: Option, } impl SystemClipboard for Clipboard { @@ -239,18 +240,17 @@ mod win { } } self.seq = raw::seq_num(); - self.text = text_of(flavours); result } fn holds_ours(&mut self) -> bool { - if self.seq.is_some() && raw::seq_num() == self.seq { - return true; - } - // A clipboard manager may have copied our data again as its own. - let Some(ours) = self.text.as_deref() else { return false }; - let Ok(_open) = clipboard_win::Clipboard::new_attempts(10) else { return false }; - read_one(TEXT).and_then(ClipboardRead::flavour).is_some_and(|f| f.data == ours.as_bytes()) + // The same text copied later by another app is a different clipboard entry. A + // clipboard manager that republishes our data is also a new entry. + self.seq.is_some() && raw::seq_num() == self.seq + } + + fn change_count(&mut self) -> Option { + raw::seq_num().map(|n| u64::from(n.get())) } fn read(&mut self, mimes: &[&'static str]) -> Option { diff --git a/apps/vectorcraft/src/control_server.rs b/apps/vectorcraft/src/control_server.rs index 508ab0daf..1a44eba28 100644 --- a/apps/vectorcraft/src/control_server.rs +++ b/apps/vectorcraft/src/control_server.rs @@ -152,6 +152,7 @@ fn spawn_connection( fn serve_socket(stream: TcpStream, timeouts: ConnectionTimeouts, handle: impl FnMut(String, Value) -> Option) { let Ok(read) = stream.try_clone() else { return }; + let closer = stream.try_clone().ok(); let read = DeadlineReader::new(read, timeouts.idle, timeouts.line); // Replies reach the writer in [`REPLY_CHUNK`] pieces ([`write_line`]), so checking the remaining // budget costs one socket option call per piece, not one per JSON token. @@ -166,6 +167,33 @@ fn serve_socket(stream: TcpStream, timeouts: ConnectionTimeouts, handle: impl Fn }, DeadlineWriter::begin_reply, ); + if let Some(s) = closer { + close_gracefully(s); + } +} + +/// How long [`close_gracefully`] waits for the peer to close its side. +const CLOSE_DRAIN: Duration = Duration::from_secs(1); + +/// The most [`close_gracefully`] reads from a peer that keeps sending. +const MAX_DRAIN: usize = 1 << 20; + +/// Ends a connection: no more replies (the peer reads them all, then end of stream), then what the +/// peer still sends is read and dropped until it closes too, for at most [`CLOSE_DRAIN`] and +/// [`MAX_DRAIN`] bytes. On Windows, dropping a socket with unread input resets the connection, and +/// the reset can discard replies the peer has not read yet. +fn close_gracefully(mut s: TcpStream) { + // Each step is best effort: a peer that is already gone needs none of them. + let _ = s.shutdown(std::net::Shutdown::Write); + let _ = s.set_read_timeout(Some(CLOSE_DRAIN)); + let deadline = std::time::Instant::now() + CLOSE_DRAIN; + let (mut buf, mut left) = ([0u8; 4096], MAX_DRAIN); + while left > 0 && std::time::Instant::now() < deadline { + match s.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => left = left.saturating_sub(n), + } + } } /// One parsed line. diff --git a/apps/vectorcraft/src/control_server/tests.rs b/apps/vectorcraft/src/control_server/tests.rs index e3d946ef0..7aa290e01 100644 --- a/apps/vectorcraft/src/control_server/tests.rs +++ b/apps/vectorcraft/src/control_server/tests.rs @@ -58,7 +58,12 @@ fn idle_control_socket_releases_its_worker() { fn idle_expiry_closes_without_writing_a_line() { let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("no request expected")); let mut got = Vec::new(); - client.read_to_end(&mut got).unwrap(); + match client.read_to_end(&mut got) { + // On Windows the server's close can reach the client as a reset. + Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => {} + Ok(_) => {} + Err(e) => panic!("reading the idle connection: {e}"), + } assert_eq!(String::from_utf8_lossy(&got), "", "the app wrote to an idle connection"); expect_socket_deadline(client, done, worker); } @@ -329,12 +334,13 @@ fn tcp_connection_is_closed_after_junk() { let addr = listener.local_addr().unwrap(); let server = std::thread::spawn(move || { let (s, _) = listener.accept().unwrap(); - let read = s.try_clone().unwrap(); - serve_lines(read, s, |_, _| Some(json!({"ok": true}))); + serve_socket(s, ConnectionTimeouts::default(), |_, _| Some(json!({"ok": true}))); }); let mut c = TcpStream::connect(addr).unwrap(); c.set_read_timeout(Some(WAIT)).unwrap(); - c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap(); + // The server closes gracefully ([`close_gracefully`]): the client reads both replies and then + // the end of the stream, not a reset. + c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n").unwrap(); let mut text = String::new(); c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection server.join().unwrap(); diff --git a/apps/vectorcraft/src/gpu.rs b/apps/vectorcraft/src/gpu.rs index 00f92405e..d7d917ed4 100644 --- a/apps/vectorcraft/src/gpu.rs +++ b/apps/vectorcraft/src/gpu.rs @@ -753,13 +753,22 @@ enum FirstFrame { Shown, /// Overdue. Hung, + /// Overdue, but the UI is still building the first frame: the app's own work (on a slow + /// machine, or with antivirus reading every font file), not the adapter, which has been given + /// nothing to show yet. + Busy, } -/// The window's start after `frames` frames run, `since` its first frame was due. -fn first_frame(frames: u64, since: Duration, minimized: bool) -> FirstFrame { +/// The window's start after `frames` frames run, `since` its first frame was due and +/// `since_built` the first frame's UI pass ended (`None` while it runs). A frame counts once its +/// UI pass ends, before it is painted, so the adapter's time starts there: with no frame run, the +/// UI thread is in the app's code, and starting again without the adapter wouldn't help (#5160). +fn first_frame(frames: u64, since: Duration, since_built: Option, minimized: bool) -> FirstFrame { if frames >= 2 || minimized { FirstFrame::Shown - } else if since >= FIRST_FRAME_TIMEOUT { + } else if frames == 0 { + if since >= FIRST_FRAME_TIMEOUT { FirstFrame::Busy } else { FirstFrame::Wait } + } else if since_built.unwrap_or(since) >= FIRST_FRAME_TIMEOUT { FirstFrame::Hung } else { FirstFrame::Wait @@ -767,18 +776,34 @@ fn first_frame(frames: u64, since: Duration, minimized: bool) -> FirstFrame { } /// Watch the window's first frame from another thread: once it reaches the screen, clear the start -/// from [`START_FILE`]; when it hasn't reached the screen [`FIRST_FRAME_TIMEOUT`] after the app was -/// created ([`Startup::ready`]), start the app again without the adapter, as [`finish`] does for -/// one that failed, and end this process, whose UI thread is stuck in the graphics driver. +/// from [`START_FILE`]; when it hasn't reached the screen [`FIRST_FRAME_TIMEOUT`] after its UI pass +/// ended, start the app again without the adapter, as [`finish`] does for one that failed, and end +/// this process, whose UI thread is stuck in the graphics driver. A first frame still being built +/// that long is waited for, with a note in the log. pub fn watch_first_frame(startup: Arc) { + let mut built: Option = None; + let mut noted = false; let watch = move || loop { std::thread::sleep(Duration::from_millis(250)); let Some(due) = startup.ready.get() else { continue }; let ui = startup.ui.get(); let frames = ui.map_or(0, egui::Context::cumulative_frame_nr); + if frames >= 1 && built.is_none() { + built = Some(Instant::now()); + } let minimized = ui.and_then(|c| c.input(|i| i.viewport().minimized)).unwrap_or(false); - match first_frame(frames, due.elapsed(), minimized) { + match first_frame(frames, due.elapsed(), built.map(|t| t.elapsed()), minimized) { FirstFrame::Wait => continue, + FirstFrame::Busy => { + if !noted { + log::warn!( + "the window's first frame is still being built after {}s: waiting for it (the app's work, not the graphics adapter)", + FIRST_FRAME_TIMEOUT.as_secs() + ); + noted = true; + } + continue; + } FirstFrame::Shown => { startup.presented(); return; diff --git a/apps/vectorcraft/src/gpu/tests.rs b/apps/vectorcraft/src/gpu/tests.rs index b1511a232..40d84934f 100644 --- a/apps/vectorcraft/src/gpu/tests.rs +++ b/apps/vectorcraft/src/gpu/tests.rs @@ -366,14 +366,19 @@ fn gpus_without_pci_ids_are_told_apart_by_name() { fn a_first_frame_that_never_reaches_the_screen_hangs_its_adapter() { let s = Duration::from_secs; // The second frame began: the first was presented. - assert_eq!(first_frame(2, s(60), false), FirstFrame::Shown); - // Still drawing (or presenting) the first frame: waited for, then given up on (#964). - for frames in [0, 1] { - assert_eq!(first_frame(frames, s(3), false), FirstFrame::Wait); - assert_eq!(first_frame(frames, FIRST_FRAME_TIMEOUT, false), FirstFrame::Hung); - } + assert_eq!(first_frame(2, s(60), Some(s(1)), false), FirstFrame::Shown); + // The first frame built and not yet presented: waited for, then given up on (#964), counting + // from when its UI pass ended. + assert_eq!(first_frame(1, s(3), Some(s(1)), false), FirstFrame::Wait); + assert_eq!(first_frame(1, FIRST_FRAME_TIMEOUT, Some(FIRST_FRAME_TIMEOUT), false), FirstFrame::Hung); + assert_eq!(first_frame(1, s(45), Some(s(5)), false), FirstFrame::Wait, "a first frame built slowly gets its full time to show"); + assert_eq!(first_frame(1, FIRST_FRAME_TIMEOUT, None, false), FirstFrame::Hung); + // The first frame still being built: the app's work, not the adapter's (#5160). + assert_eq!(first_frame(0, s(3), None, false), FirstFrame::Wait); + assert_eq!(first_frame(0, FIRST_FRAME_TIMEOUT, None, false), FirstFrame::Busy); + assert_eq!(first_frame(0, s(600), None, false), FirstFrame::Busy, "never a restart without an adapter"); // A window minimized at the start may not draw again until restored. - assert_eq!(first_frame(1, s(60), true), FirstFrame::Shown); + assert_eq!(first_frame(1, s(60), Some(s(60)), true), FirstFrame::Shown); // A hung adapter is left out once, like one that failed. let skip = vec!["Dx12:10de:28a0".to_string()]; assert_eq!(restart_without(true, 1, Some("Dx12:10de:28a0"), &skip), None); diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index e25332b61..2ca282d74 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -44,19 +44,50 @@ macro_rules! dialog_settings { dialog_settings!(rfd::FileDialog, rfd::AsyncFileDialog); +/// On Linux and the BSDs the dialog is GTK's or the desktop portal's, which match a file type's +/// `*.ext` patterns case-sensitively. +const CASE_SENSITIVE_FILTERS: bool = + cfg!(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")); + +/// The extensions an open dialog's filter is given for `exts`: with `any_case`, each extension +/// that has a letter is followed by a pattern with each letter as a `[xX]` pair, so the filter for +/// `dib` also lists `image.DIB` and `image.Dib`. +fn open_extensions(exts: &[impl AsRef], any_case: bool) -> Vec { + let mut out = Vec::with_capacity(exts.len() * 2); + for e in exts { + let e = e.as_ref(); + out.push(e.to_string()); + if any_case && e.chars().any(|c| c.is_ascii_alphabetic()) { + out.push( + e.chars() + .map(|c| if c.is_ascii_alphabetic() { format!("[{}{}]", c.to_ascii_lowercase(), c.to_ascii_uppercase()) } else { c.to_string() }) + .collect(), + ); + } + } + out +} + /// `d` set up for `request`: its title, file types, folder and suggested name. fn dialog_for(d: D, request: &PickRequest) -> D { let pick = match request { PickRequest::Open(pick) | PickRequest::Save(pick) => pick, PickRequest::OpenMany => { return fileio::place_filters().fold(d.title(vectorcraft_ui_egui::i18n::t("Place")), |d, (name, exts)| { - d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts) + d.filter( + vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), + &open_extensions(exts, CASE_SENSITIVE_FILTERS), + ) }); } PickRequest::Folder => return d, }; + let any_case = CASE_SENSITIVE_FILTERS && matches!(request, PickRequest::Open(_)); let d = pick.filters.iter().fold(d, |d, (name, exts)| { - d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name.as_str()), exts) + d.filter( + vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name.as_str()), + &open_extensions(exts, any_case), + ) }); let d = match &pick.folder { Some(folder) => d.folder(folder), @@ -151,6 +182,22 @@ fn reveal_command(path: &str) -> std::process::Command { c } +/// Read a whole file. A device or pipe may never end: only a regular file is read. +fn read_file(path: &str) -> Result, String> { + if !std::fs::metadata(path).map_err(|e| e.to_string())?.is_file() { + return Err(format!("{path}: not a file")); + } + std::fs::read(path).map_err(|e| e.to_string()) +} + +#[cfg(test)] +#[test] +fn read_file_refuses_what_is_not_a_regular_file() { + assert!(read_file(&std::env::temp_dir().to_string_lossy()).is_err()); + #[cfg(unix)] + assert!(read_file("/dev/zero").is_err()); +} + /// Write a file the safe way: a failed write keeps the old file ([`fileio::write_atomic`]). fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> { fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string()) @@ -277,7 +324,7 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { // Windows reads it from the pen's pointer messages (`windows_stylus`). #[cfg(windows)] stylus: Some(windows_stylus::start()), - read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), + read: Some(Box::new(read_file)), write: Some(Box::new(write_file)), exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())), // Background Save and Export write from a worker thread. @@ -300,6 +347,8 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { reveal: Some(Box::new(reveal)), // Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders. open_file: Some(Box::new(open_file)), + // Edit Original of a raster image with Use System Defaults for Edit Original off (#5236). + open_in_photocraft: Some(Box::new(open_in_photocraft)), pick_folder: Some(Box::new(move || pick_now(PickRequest::Folder, &p4).into_iter().next())), // Sample Color from Screen: the system's color sampler. #[cfg(target_os = "macos")] @@ -334,3 +383,395 @@ fn open_file(path: &str) -> Result<(), String> { c.arg(path); c.spawn().map(|_| ()).map_err(|e| format!("can't open {path}: {e}")) } + +/// Edit Original with Preferences › File Handling › Use System Defaults for Edit Original off +/// (#5236): open the raster image `path` in PhotoCraft, found only where it installs +/// ([`find_photocraft`]); `Ok(false)` when it isn't installed. PhotoCraft gets the path as its only +/// argument and is never started through a shell. +fn open_in_photocraft(path: &str) -> Result { + explorer_safe(path)?; + let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(std::path::PathBuf::from); + let Some(app) = find_photocraft(home.as_deref(), std::env::var_os("PATH").as_deref()) else { return Ok(false) }; + #[cfg(target_os = "macos")] + let mut c = { + let mut c = std::process::Command::new("open"); + c.arg("-a").arg(&app).arg(path); + c + }; + #[cfg(not(target_os = "macos"))] + let mut c = { + let mut c = std::process::Command::new(&app); + c.arg(path); + c + }; + // macOS: `open` returns once Launch Services has the request, so a short wait reports an app + // it can't open (a folder named `.app` that isn't a bundle). The wait is capped so a stalled + // `open` never holds the window: past it, the launch is left to run. Elsewhere the editor + // itself runs: waiting would last until it closes. + #[cfg(target_os = "macos")] + { + const WAIT: std::time::Duration = std::time::Duration::from_secs(2); + let fail = |why: String| format!("can't open {path} in {}: {why}", app.display()); + let mut child = c.stderr(std::process::Stdio::piped()).spawn().map_err(|e| fail(e.to_string()))?; + let started = std::time::Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) if status.success() => return Ok(true), + Ok(Some(status)) => { + let mut why = String::new(); + if let Some(mut err) = child.stderr.take() { + // Best effort: without its message the exit status says why. + let _ = std::io::Read::read_to_string(&mut err, &mut why); + } + let why = why.trim().to_string(); + return Err(fail(if why.is_empty() { status.to_string() } else { why })); + } + Ok(None) if started.elapsed() < WAIT => std::thread::sleep(std::time::Duration::from_millis(20)), + Ok(None) => { + // Reaped off the UI thread once it ends. + std::thread::spawn(move || { + let _ = child.wait(); + }); + return Ok(true); + } + Err(e) => return Err(fail(e.to_string())), + } + } + } + #[cfg(not(target_os = "macos"))] + c.spawn().map(|_| true).map_err(|e| format!("can't open {path} in {}: {e}", app.display())) +} + +/// PhotoCraft where it is installed: the first of [`photocraft_candidates`] that is an application. +pub(crate) fn find_photocraft(home: Option<&std::path::Path>, path_var: Option<&std::ffi::OsStr>) -> Option { + photocraft_candidates(home, path_var).into_iter().find(|p| check_app(p).is_ok()) +} + +/// Where PhotoCraft may be installed: the Applications folders on macOS; on Windows where its +/// installer puts it ([`windows_candidates`]); else a `photocraft` program on the `PATH`. +pub(crate) fn photocraft_candidates(home: Option<&std::path::Path>, path_var: Option<&std::ffi::OsStr>) -> Vec { + if cfg!(target_os = "macos") { + let mut v = vec![std::path::PathBuf::from("/Applications/PhotoCraft.app")]; + v.extend(home.map(|h| h.join("Applications/PhotoCraft.app"))); + return v; + } + let name = if cfg!(windows) { "photocraft.exe" } else { "photocraft" }; + let on_path = path_var.map(|p| std::env::split_paths(p).map(|dir| dir.join(name)).collect()).unwrap_or_default(); + if cfg!(windows) { + let folders: Vec = [ + std::env::var_os("LOCALAPPDATA").map(|d| std::path::PathBuf::from(d).join("Programs")), + std::env::var_os("ProgramW6432").map(std::path::PathBuf::from), + std::env::var_os("ProgramFiles").map(std::path::PathBuf::from), + ] + .into_iter() + .flatten() + .collect(); + return windows_candidates(registered_photocraft(), &folders, on_path); + } + on_path +} + +/// Windows: PhotoCraft's installer puts it in `ArtCraft\PhotoCraft` under a folder of +/// `program_folders` (for the current user `%LOCALAPPDATA%\Programs`, for everyone Program Files) +/// and registers its path under App Paths, which also covers a folder chosen in setup. So: the +/// `registered` paths first, then those folders, then `on_path`. +pub(crate) fn windows_candidates( + registered: Vec, + program_folders: &[std::path::PathBuf], + on_path: Vec, +) -> Vec { + let mut v = registered; + for f in program_folders { + let p = f.join("ArtCraft").join("PhotoCraft").join("photocraft.exe"); + if !v.contains(&p) { + v.push(p); + } + } + v.extend(on_path); + v +} + +/// The paths PhotoCraft's installer registers under App Paths, for the current user and then for +/// everyone. +#[cfg(windows)] +fn registered_photocraft() -> Vec { + use windows::Win32::System::Registry::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE}; + [HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE] + .into_iter() + .filter_map(|hive| registry_default_path(hive, r"Software\Microsoft\Windows\CurrentVersion\App Paths\photocraft.exe")) + .collect() +} + +#[cfg(not(windows))] +fn registered_photocraft() -> Vec { + Vec::new() +} + +/// The default `REG_SZ` value of `subkey` under `hive` as a path ([`registered_path`]), or `None` +/// when the key or the value is missing. The value is read as UTF-16, so a folder name in any +/// script comes back as stored. +#[cfg(windows)] +#[allow(unsafe_code)] +fn registry_default_path(hive: windows::Win32::System::Registry::HKEY, subkey: &str) -> Option { + use windows::Win32::System::Registry::{RRF_RT_REG_SZ, RegGetValueW}; + use windows::core::PCWSTR; + let subkey: Vec = subkey.encode_utf16().chain([0]).collect(); + let mut size = 0u32; + // SAFETY: `subkey` is a NUL-terminated UTF-16 string that outlives the call, a null value name + // reads the default value, and with no buffer the call only writes the value's size to `size`. + unsafe { RegGetValueW(hive, PCWSTR(subkey.as_ptr()), PCWSTR::null(), RRF_RT_REG_SZ, None, None, Some(&mut size)) }.ok().ok()?; + let mut value = vec![0u16; (size as usize).div_ceil(2)]; + let mut size = u32::try_from(value.len() * 2).ok()?; + // SAFETY: as above, and `value` holds the `size` bytes the call may write. + unsafe { RegGetValueW(hive, PCWSTR(subkey.as_ptr()), PCWSTR::null(), RRF_RT_REG_SZ, None, Some(value.as_mut_ptr().cast()), Some(&mut size)) } + .ok() + .ok()?; + registered_path(&value) +} + +/// The path in a registry string value read as UTF-16, up to its first NUL, without surrounding +/// spaces or quotes, like `C:\Program Files\ArtCraft\PhotoCraft\photocraft.exe`. +#[cfg_attr(not(any(windows, test)), allow(dead_code))] +pub(crate) fn registered_path(value: &[u16]) -> Option { + let end = value.iter().position(|&c| c == 0).unwrap_or(value.len()); + let text = String::from_utf16(&value[..end]).ok()?; + Some(text.trim().trim_matches('"')).filter(|v| !v.is_empty()).map(std::path::PathBuf::from) +} + +/// Is `app` an application Edit Original may start: an `.app` bundle on macOS, an `.exe` on +/// Windows, an executable file elsewhere? Anything else (a document, a folder, a script on +/// Windows) found where PhotoCraft installs is not taken for it. +pub(crate) fn check_app(app: &std::path::Path) -> Result<(), String> { + let ext = app.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); + let ok = if cfg!(target_os = "macos") { + ext == "app" && app.is_dir() + } else if cfg!(windows) { + ext == "exe" && app.is_file() + } else { + executable_file(app) + }; + if ok { Ok(()) } else { Err(format!("{} isn't an application Edit Original can open files in", app.display())) } +} + +#[cfg(unix)] +fn executable_file(p: &std::path::Path) -> bool { + use std::os::unix::fs::PermissionsExt as _; + std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) +} + +#[cfg(not(unix))] +fn executable_file(p: &std::path::Path) -> bool { + p.is_file() +} + +#[cfg(test)] +mod open_with_tests { + use super::{check_app, find_photocraft, photocraft_candidates, registered_path, windows_candidates}; + + /// A fresh, empty folder under the system's temporary folder. + fn scratch(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("vc-open-with-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + /// #5236: only an application counts as PhotoCraft, never a document, a folder or a missing + /// path. + #[test] + fn only_applications_are_started() { + let d = scratch("check"); + let doc = d.join("notes.txt"); + std::fs::write(&doc, "hi").unwrap(); + assert!(check_app(&doc).is_err(), "a document"); + assert!(check_app(&d).is_err(), "a folder"); + assert!(check_app(&d.join("Missing.app")).is_err(), "a missing path"); + if cfg!(target_os = "macos") { + let app = d.join("Editor.app"); + std::fs::create_dir_all(&app).unwrap(); + assert!(check_app(&app).is_ok(), "an .app bundle"); + } + #[cfg(all(unix, not(target_os = "macos")))] + { + use std::os::unix::fs::PermissionsExt as _; + let tool = d.join("editor"); + std::fs::write(&tool, "#!/bin/sh\n").unwrap(); + assert!(check_app(&tool).is_err(), "not executable yet"); + std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(check_app(&tool).is_ok(), "an executable file"); + } + let _ = std::fs::remove_dir_all(&d); + } + + /// PhotoCraft is looked for only where it installs. + #[test] + fn photocraft_is_found_where_it_installs() { + let d = scratch("find"); + if cfg!(target_os = "macos") { + let candidates = photocraft_candidates(Some(&d), None); + assert_eq!(candidates.first().map(|p| p.to_string_lossy().into_owned()).as_deref(), Some("/Applications/PhotoCraft.app")); + assert!(candidates.contains(&d.join("Applications/PhotoCraft.app")), "{candidates:?}"); + std::fs::create_dir_all(d.join("Applications/PhotoCraft.app")).unwrap(); + assert!(find_photocraft(Some(&d), None).unwrap().ends_with("PhotoCraft.app")); + } else { + let bin = d.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let path_var = std::env::join_paths([&bin]).unwrap(); + // On Windows an installed PhotoCraft (registered, or in a program folder) comes first, so + // there only the `PATH` candidate is checked. + if !cfg!(windows) { + assert!(find_photocraft(None, Some(&path_var)).is_none(), "not installed"); + } + let exe = bin.join(if cfg!(windows) { "photocraft.exe" } else { "photocraft" }); + std::fs::write(&exe, "").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + if cfg!(windows) { + assert_eq!(photocraft_candidates(None, Some(&path_var)).last(), Some(&exe)); + assert!(check_app(&exe).is_ok()); + } else { + assert_eq!(find_photocraft(None, Some(&path_var)), Some(exe)); + } + } + let _ = std::fs::remove_dir_all(&d); + } + + /// #5327 review: on Windows PhotoCraft is looked for where its installer puts it: the path it + /// registers, then `ArtCraft\PhotoCraft` in the per-user and per-machine program folders, then + /// the `PATH`. + #[test] + fn windows_looks_where_the_photocraft_installer_puts_it() { + use std::path::PathBuf; + let registered = vec![PathBuf::from("D:/Tools/PhotoCraft/photocraft.exe")]; + let folders = [PathBuf::from("C:/Users/me/AppData/Local/Programs"), PathBuf::from("C:/Program Files"), PathBuf::from("C:/Program Files")]; + let on_path = vec![PathBuf::from("C:/bin/photocraft.exe")]; + let exe = |f: &str| PathBuf::from(f).join("ArtCraft").join("PhotoCraft").join("photocraft.exe"); + assert_eq!( + windows_candidates(registered, &folders, on_path), + [ + PathBuf::from("D:/Tools/PhotoCraft/photocraft.exe"), + exe("C:/Users/me/AppData/Local/Programs"), + exe("C:/Program Files"), + PathBuf::from("C:/bin/photocraft.exe"), + ] + ); + } + + /// The registered path is read from the UTF-16 value up to its NUL, spaces, quotes and + /// characters outside ASCII included; an empty value or one that is not UTF-16 gives no path. + #[test] + fn the_registered_path_is_read_from_the_utf16_value() { + let wide = |s: &str| s.encode_utf16().chain([0, 0]).collect::>(); + assert_eq!( + registered_path(&wide(r"C:\Program Files\ArtCraft\PhotoCraft\photocraft.exe")), + Some(std::path::PathBuf::from(r"C:\Program Files\ArtCraft\PhotoCraft\photocraft.exe")) + ); + assert_eq!( + registered_path(&wide(" \"C:\\Prögram Fïles\\Тест\\写真\\photocraft.exe\" ")), + Some(std::path::PathBuf::from(r"C:\Prögram Fïles\Тест\写真\photocraft.exe")) + ); + assert_eq!(registered_path(&wide("")), None); + assert_eq!(registered_path(&[0xD800, 0]), None); + } + + /// On Windows a registered path with characters outside the console's code page is read as + /// stored, from a scratch key under the current user's hive. + #[cfg(windows)] + #[test] + fn a_registered_path_outside_ascii_is_read_as_stored() { + let key = format!(r"Software\VectorCraftTest-app-paths-{}", std::process::id()); + let path = r"C:\Prögram Fïles\Тест\写真\photocraft.exe"; + let reg = std::env::var_os("SystemRoot").map(std::path::PathBuf::from).unwrap_or_else(|| r"C:\Windows".into()).join(r"System32\reg.exe"); + let full = format!(r"HKCU\{key}"); + let added = std::process::Command::new(®).args(["add", &full, "/ve", "/t", "REG_SZ", "/d", path, "/f"]).output().unwrap(); + assert!(added.status.success(), "{added:?}"); + let read = super::registry_default_path(windows::Win32::System::Registry::HKEY_CURRENT_USER, &key); + let _ = std::process::Command::new(®).args(["delete", &full, "/f"]).output(); + assert_eq!(read, Some(std::path::PathBuf::from(path))); + assert_eq!(super::registry_default_path(windows::Win32::System::Registry::HKEY_CURRENT_USER, &key), None, "deleted"); + } +} + +#[cfg(test)] +mod dialog_tests { + use super::{CASE_SENSITIVE_FILTERS, DialogSettings, FilePick, PickRequest, dialog_for, fileio, open_extensions}; + + /// The file types a dialog was given, by label. + #[derive(Default)] + struct Filters(Vec<(String, Vec)>); + + impl DialogSettings for Filters { + fn parent(self, _window: &winit::window::Window) -> Self { + self + } + fn title(self, _title: &str) -> Self { + self + } + fn filter(mut self, name: &str, exts: &[impl ToString]) -> Self { + self.0.push((name.to_string(), exts.iter().map(ToString::to_string).collect())); + self + } + fn folder(self, _folder: &str) -> Self { + self + } + fn file_name(self, _name: &str) -> Self { + self + } + } + + /// Does the case-sensitive pattern `*.{ext}` match `name`? `ext` may hold literal characters, + /// `*` and `[..]` sets. + fn matches(ext: &str, name: &str) -> bool { + fn here(p: &[char], n: &[char]) -> bool { + match p.split_first() { + None => n.is_empty(), + Some(('*', rest)) => (0..=n.len()).any(|i| here(rest, &n[i..])), + Some(('[', rest)) => { + let Some(end) = rest.iter().position(|&c| c == ']') else { return false }; + n.split_first().is_some_and(|(c, n)| rest[..end].contains(c) && here(&rest[end + 1..], n)) + } + Some((c, rest)) => n.split_first().is_some_and(|(d, n)| d == c && here(rest, n)), + } + } + let pattern: Vec = format!("*.{ext}").chars().collect(); + here(&pattern, &name.chars().collect::>()) + } + + /// Where filters match case-sensitively, every extension of File › Open and Place lists files + /// whose extension is in capitals or mixed case, `image.DIB` and `image.Pns` included. + #[test] + fn open_and_place_filters_list_extensions_in_any_case() { + let all = fileio::open_filters().chain(fileio::place_filters()); + for (label, exts) in all { + let patterns = open_extensions(exts, true); + for ext in exts { + let mut mixed = ext.to_ascii_uppercase(); + mixed.replace_range(..1, &ext[..1]); + for name in [format!("a.{ext}"), format!("a.{}", ext.to_ascii_uppercase()), format!("a.{mixed}")] { + assert!(patterns.iter().any(|p| matches(p, &name)), "{label}: {patterns:?} don't match {name}"); + } + } + } + assert_eq!(open_extensions(&["dib", "jp2", "*"], true), ["dib", "[dD][iI][bB]", "jp2", "[jJ][pP]2", "*"]); + assert_eq!(open_extensions(&["dib", "PNS"], false), ["dib", "PNS"]); + } + + /// Open and Place dialogs get the any-case patterns on Linux and the BSDs; a save dialog's file + /// types stay as listed. + #[test] + fn open_dialogs_get_any_case_patterns_where_filters_are_case_sensitive() { + let dib = vec![FilePick::filter("BMP", &["bmp", "dib"])]; + let pick = FilePick { filters: dib.clone(), ..Default::default() }; + let expected = |any_case: bool| open_extensions(&["bmp", "dib"], any_case); + let opened = dialog_for(Filters::default(), &PickRequest::Open(pick.clone())).0; + assert_eq!(opened[0].1, expected(CASE_SENSITIVE_FILTERS)); + let saved = dialog_for(Filters::default(), &PickRequest::Save(pick)).0; + assert_eq!(saved, dib); + let placed = dialog_for(Filters::default(), &PickRequest::OpenMany).0; + let first = fileio::place_filters().next().map(|(_, exts)| open_extensions(exts, CASE_SENSITIVE_FILTERS)); + assert_eq!(placed.first().map(|(_, exts)| exts.clone()), first); + } +} diff --git a/apps/vectorcraft/src/portal_save.rs b/apps/vectorcraft/src/portal_save.rs index b6dd3cc8e..0357829ae 100644 --- a/apps/vectorcraft/src/portal_save.rs +++ b/apps/vectorcraft/src/portal_save.rs @@ -96,6 +96,12 @@ fn answer(code: u32, results: &HashMap) -> Result) = v.try_clone().ok()?.try_into().ok()?; Some(label) @@ -103,9 +109,9 @@ fn answer(code: u32, results: &HashMap) -> Result Option { +/// `file:///home/me/A%20b.svg` → `/home/me/A b.svg` (its bytes); `None` for a file elsewhere +/// (`file://host/`) or another scheme. +fn file_uri_path(uri: &str) -> Option> { let rest = uri.strip_prefix("file://")?; let rest = rest.strip_prefix("localhost").unwrap_or(rest); if !rest.starts_with('/') { @@ -126,8 +132,7 @@ fn file_uri_path(uri: &str) -> Option { } } } - // `show_dialog` makes rfd's path a string with `to_string_lossy` too. - Some(String::from_utf8_lossy(&out).into_owned()) + Some(out) } /// `saved` as the answer of the save dialog for `request`: the path, then the first extension of @@ -406,6 +411,7 @@ mod tests { #[test] fn file_uris_become_local_paths() { + let file_uri_path = |uri: &str| super::file_uri_path(uri).map(|b| String::from_utf8(b).unwrap()); assert_eq!(file_uri_path("file:///home/me/Untitled-1.vectorcraft").as_deref(), Some("/home/me/Untitled-1.vectorcraft")); assert_eq!(file_uri_path("file:///home/me/A%20b%C3%A9.svg").as_deref(), Some("/home/me/A bé.svg")); assert_eq!(file_uri_path("file://localhost/tmp/x.pdf").as_deref(), Some("/tmp/x.pdf")); @@ -413,6 +419,9 @@ mod tests { assert_eq!(file_uri_path("file:///tmp/100%+1%2").as_deref(), Some("/tmp/100%+1%2")); assert_eq!(file_uri_path("file://host/x.svg"), None); assert_eq!(file_uri_path("sftp://host/x.svg"), None); + // Not UTF-8: the bytes as they are, which `answer` refuses rather than changing them. + assert_eq!(super::file_uri_path("file:///tmp/a%FF.svg"), Some(b"/tmp/a\xff.svg".to_vec())); + assert!(matches!(answer(0, &results("file:///tmp/a%FF.svg", None)), Ok(None))); } #[test] diff --git a/apps/vectorcraft/src/prefs.rs b/apps/vectorcraft/src/prefs.rs index cf4e59b28..a6daacb83 100644 --- a/apps/vectorcraft/src/prefs.rs +++ b/apps/vectorcraft/src/prefs.rs @@ -62,7 +62,12 @@ pub(crate) fn keep_prefs_copy(path: &std::path::Path, bytes: &[u8], stem: &str) use std::io::Write as _; for n in 1..100u32 { let name = if n == 1 { format!("{stem}.json") } else { format!("{stem}-{n}.json") }; - match std::fs::OpenOptions::new().write(true).create_new(true).open(path.with_file_name(name)) { + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + // The preferences hold recent document paths: the copy is owner-only, whatever the umask. + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + match options.open(path.with_file_name(name)) { Ok(mut file) => { if file.write_all(bytes).is_ok() { return; @@ -312,6 +317,8 @@ pub(crate) fn prefs_bytes(app: &VectorcraftApp) -> serde_json::Result> { /// [`prefs_bytes`] as JSON. fn prefs_value(app: &VectorcraftApp) -> serde_json::Result { let mut ui = app.ui.clone(); + // An open dialog is never restored, and its fields can hold passwords (PDF import and save). + ui.dialog = None; ui.engine_prefs = app.session.prefs.to_json(); serde_json::to_value(&ui) } @@ -324,6 +331,19 @@ mod tests { /// Two windows open at once (#4625): the one that saves last keeps the other's changes and /// adds its own, instead of putting back the preferences it started from. + #[cfg(unix)] + #[test] + fn a_kept_preferences_copy_is_owner_only() { + use std::os::unix::fs::PermissionsExt as _; + let dir = std::env::temp_dir().join(format!("vc-prefs-copy-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + super::keep_prefs_copy(&dir.join("ui.json"), b"{}", "ui.unreadable"); + let mode = std::fs::metadata(dir.join("ui.unreadable.json")).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn a_window_saves_only_what_it_changed_over_another_windows_save() { let base = json!({"workspace": "Essentials", "toolbar": true, "engine_prefs": {"uiAccent": "blue", "uiScaling": 1.0}}); diff --git a/apps/vectorcraft/src/profile.rs b/apps/vectorcraft/src/profile.rs index 4030d0aea..8ef035583 100644 --- a/apps/vectorcraft/src/profile.rs +++ b/apps/vectorcraft/src/profile.rs @@ -149,6 +149,15 @@ fn copy_dir(from: &Path, to: &Path) -> Result<(), String> { } } +/// Make folder `dir`, owner-only on Unix. +fn private_dir(dir: &Path) -> std::io::Result<()> { + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); + builder.create(dir) +} + fn copy_dir_into(from: &Path, to: &Path, skip: &mut impl FnMut(String)) { let entries = match std::fs::read_dir(from) { Ok(entries) => entries, @@ -165,7 +174,12 @@ fn copy_dir_into(from: &Path, to: &Path, skip: &mut impl FnMut(String)) { let (src, dst) = (entry.path(), to.join(entry.file_name())); // A symbolic link is copied as what it points to, or skipped when that is a folder. let copied = match entry.file_type() { - Ok(kind) if kind.is_dir() => std::fs::create_dir_all(&dst).map(|()| copy_dir_into(&src, &dst, skip)), + // The copy keeps the folder's permissions (a private recovery folder stays private): + // owner-only while it fills, then the source's. + Ok(kind) if kind.is_dir() => private_dir(&dst).and_then(|()| { + copy_dir_into(&src, &dst, skip); + std::fs::set_permissions(&dst, std::fs::metadata(&src)?.permissions()) + }), Ok(_) => std::fs::copy(&src, &dst).map(|_| ()), Err(e) => Err(e), }; @@ -387,4 +401,19 @@ mod tests { let _ = std::fs::remove_dir_all(&from); let _ = std::fs::remove_dir_all(&to); } + + #[cfg(unix)] + #[test] + fn a_copied_folder_keeps_its_permissions() { + use std::os::unix::fs::PermissionsExt as _; + let from = temp("copy-mode-from"); + let to = temp("copy-mode-to"); + std::fs::create_dir_all(from.join("recovery")).unwrap(); + std::fs::write(from.join("recovery/copy.vectorcraft"), b"unsaved").unwrap(); + std::fs::set_permissions(from.join("recovery"), std::fs::Permissions::from_mode(0o700)).unwrap(); + copy_dir(&from, &to).unwrap(); + assert_eq!(std::fs::metadata(to.join("recovery")).unwrap().permissions().mode() & 0o777, 0o700); + let _ = std::fs::remove_dir_all(&from); + let _ = std::fs::remove_dir_all(&to); + } } diff --git a/apps/vectorcraft/src/single_instance.rs b/apps/vectorcraft/src/single_instance.rs index b8df2bb5a..f7026131c 100644 --- a/apps/vectorcraft/src/single_instance.rs +++ b/apps/vectorcraft/src/single_instance.rs @@ -329,10 +329,15 @@ mod tests { #[test] fn a_stale_endpoint_file_is_ignored_and_replaced() { let (path, dir, file) = folder("stale"); - // No listener on the recorded port any more. - let port = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port(); + // The recorded app is gone: whatever holds the port closes without a greeting. (A freed + // port can be taken by another test's listener meanwhile, which would then accept this + // connection in place of its own client's.) + let gone = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let port = gone.local_addr().unwrap().port(); write_endpoint(&path, &json!({ "port": port, "token": "t", "hello": "h" })).unwrap(); + let closed = std::thread::spawn(move || drop(gone.accept())); assert!(forward(&path, std::slice::from_ref(&file)).is_err()); + closed.join().unwrap(); // A listener that doesn't know the greeting gets neither the token nor the paths. let impostor = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); let port = impostor.local_addr().unwrap().port(); diff --git a/contributors/people.toml b/contributors/people.toml index f3c8629b7..405c471a2 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -189,3 +189,23 @@ sync_github_name = false [people.WadahBadawi] display_name = "Wadah Badawi" sync_github_name = true + +[people.JonRC] +real_name = "Jonathan Cardoso" +display_name = "Jonathan Cardoso" + +[people.vangel76] +real_name = "Valentin Klein" +display_name = "vangel76" +sync_github_name = false + +[people.FrancoJacquet] +real_name = "Franco Jacquet" + +[people.FractalEncrypt] +sync_github_name = true + +[people.sadkotheguest] +real_name = "Alexander Buraga" +display_name = "Alexander Buraga" +sync_github_name = false diff --git a/crates/affinity/src/container.rs b/crates/affinity/src/container.rs index 6b1df9510..f26948e91 100644 --- a/crates/affinity/src/container.rs +++ b/crates/affinity/src/container.rs @@ -15,6 +15,13 @@ const DOCUMENT_KIND: &[u8; 4] = b"nsrP"; /// A save appends one table; real files have one to a few hundred. const MAX_TABLES: usize = 4096; +/// Allocation records read over the whole table chain. Each save lists every entry again, so a +/// document with a few hundred saves of a few thousand entries stays far below; tables whose +/// record regions overlap (or a table chain of thousands of huge tables) can't multiply the work. +const MAX_INDEX_RECORDS: usize = 1 << 22; +/// Bytes of entry names kept by the index (after lossy UTF-8 decoding, which can triple a name). +/// Real names (`doc.dat`, `d/12`) are a few bytes each. +const MAX_INDEX_NAME_BYTES: usize = 16 << 20; /// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes. const MAX_ZSTD_WINDOW: u64 = 64 << 20; @@ -75,6 +82,9 @@ pub struct Archive<'a> { pub revisions: usize, extracted: usize, limits: Limits, + /// Entries whose extraction failed after their size was charged: asked again, they fail + /// again without being decompressed again. + failed: HashMap, } pub(crate) struct Cursor<'a> { @@ -157,7 +167,15 @@ pub fn header(bytes: &[u8]) -> Result { impl<'a> Archive<'a> { pub fn open(bytes: &'a [u8], limits: Limits) -> Result { + Self::open_within(bytes, limits, MAX_INDEX_RECORDS, MAX_INDEX_NAME_BYTES) + } + + /// [`Archive::open`] reading at most `max_records` allocation records and keeping at most + /// `max_name_bytes` bytes of entry names, both over the whole table chain. + pub(crate) fn open_within(bytes: &'a [u8], limits: Limits, max_records: usize, max_name_bytes: usize) -> Result { let header = header(bytes)?; + let mut records_left = max_records; + let mut name_bytes_left = max_name_bytes; // Newest record per id: (table date, table index, record). let mut latest: HashMap)> = HashMap::new(); let mut names: HashMap = HashMap::new(); @@ -189,6 +207,7 @@ impl<'a> Archive<'a> { let dirs = c.u16()?; c.take(1)?; for _ in 0..files { + records_left = records_left.checked_sub(1).ok_or(Error::Limit("too many archive entries"))?; let id = c.u32()?; let flag = c.u8()?; let mut record = None; @@ -224,8 +243,13 @@ impl<'a> Archive<'a> { } if flag == 0 { let len = usize::from(c.u16()?); - let name = String::from_utf8_lossy(c.take(len)?).into_owned(); - names.entry(id).or_insert(name); + let raw = c.take(len)?; + // Only the first name of an id is kept: later ones are not even decoded. + if let std::collections::hash_map::Entry::Vacant(slot) = names.entry(id) { + let name = String::from_utf8_lossy(raw); + name_bytes_left = name_bytes_left.checked_sub(name.len()).ok_or(Error::Limit("archive entry names too long"))?; + slot.insert(name.into_owned()); + } } // Equal dates: the first table in the chain (the newest) wins, as in afread. let replace = latest.get(&id).is_none_or(|(d, _, _)| date > *d); @@ -234,6 +258,7 @@ impl<'a> Archive<'a> { } } for _ in 0..dirs { + records_left = records_left.checked_sub(1).ok_or(Error::Limit("too many archive entries"))?; let len = usize::from(c.u16()?); c.take(10)?; c.take(len)?; @@ -241,12 +266,12 @@ impl<'a> Archive<'a> { } let mut entries = HashMap::new(); for (id, (_, _, record)) in latest { - if let (Some(mut e), Some(name)) = (record, names.get(&id)) { - e.name = name.clone(); + if let (Some(mut e), Some(name)) = (record, names.remove(&id)) { + e.name = name; entries.insert(e.name.clone(), e); } } - Ok(Self { bytes, header, entries, revisions: tables, extracted: 0, limits }) + Ok(Self { bytes, header, entries, revisions: tables, extracted: 0, limits, failed: HashMap::new() }) } pub fn entry(&self, name: &str) -> Option<&Entry> { @@ -279,7 +304,10 @@ impl<'a> Archive<'a> { } pub fn read(&mut self, name: &str) -> Result, Error> { - let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?.clone(); + if let Some(e) = self.failed.get(name) { + return Err(*e); + } + let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?; let size = usize::try_from(e.size).map_err(|_| Error::Limit("archive entry too large"))?; if size > self.limits.max_entry { return Err(Error::Limit("archive entry too large")); @@ -288,30 +316,41 @@ impl<'a> Archive<'a> { if total > self.limits.max_total { return Err(Error::Limit("document too large")); } - let mut c = Cursor::new(self.bytes, offset(e.offset)?); - if c.take(4)? != b"#Fil" { - return Err(Error::Malformed("archive entry has no data block")); - } - let method = e.compression & 3; - let stored_len = if matches!(method, 1 | 2) { e.compressed } else { e.size }; - let data = c.take(offset(stored_len)?)?; - let mut out = match method { - 1 => inflate(data, size)?, - 2 => unzstd(data, size)?, - _ => data.to_vec(), - }; - if out.len() != size { - return Err(Error::Malformed("archive entry size mismatch")); - } - unpredict(&mut out, e.compression)?; - if crc32fast::hash(&out) != e.crc { - return Err(Error::Malformed("archive entry checksum mismatch")); - } + // Charged before the work, and kept when it fails: an entry that fails its checksum + // can't be decompressed over and over for free. self.extracted = total; - Ok(out) + let result = extract(self.bytes, e, size); + if let Err(err) = result { + self.failed.insert(name.to_owned(), err); + } + result } } +/// Decompress entry `e` of `bytes` (declared `size` bytes), undo its predictor and check its CRC. +fn extract(bytes: &[u8], e: &Entry, size: usize) -> Result, Error> { + let mut c = Cursor::new(bytes, offset(e.offset)?); + if c.take(4)? != b"#Fil" { + return Err(Error::Malformed("archive entry has no data block")); + } + let method = e.compression & 3; + let stored_len = if matches!(method, 1 | 2) { e.compressed } else { e.size }; + let data = c.take(offset(stored_len)?)?; + let mut out = match method { + 1 => inflate(data, size)?, + 2 => unzstd(data, size)?, + _ => data.to_vec(), + }; + if out.len() != size { + return Err(Error::Malformed("archive entry size mismatch")); + } + unpredict(&mut out, e.compression)?; + if crc32fast::hash(&out) != e.crc { + return Err(Error::Malformed("archive entry checksum mismatch")); + } + Ok(out) +} + /// Read at most `size + 1` bytes so a stream that decodes past its declared size is caught /// without unbounded allocation. fn read_bounded(mut r: impl Read, size: usize) -> Result, Error> { diff --git a/crates/affinity/src/overlay.rs b/crates/affinity/src/overlay.rs index 170c50ec3..80f54f68e 100644 --- a/crates/affinity/src/overlay.rs +++ b/crates/affinity/src/overlay.rs @@ -332,7 +332,7 @@ fn pixels(img: &mut Image, o: &Overlay) -> bool { }; let to_unit = match o.source { Source::Gradient(g) => match invert(g.transform) { - Some(m) => Some((img.transform.then(m), g)), + Some(m) => Some((img.transform.then(m), g, stop_reach(g))), None => return false, }, Source::Solid(_) => None, @@ -343,9 +343,9 @@ fn pixels(img: &mut Image, o: &Overlay) -> bool { } let (s, sa) = match (solid, &to_unit) { (Some(s), _) => s, - (None, Some((m, g))) => { + (None, Some((m, g, reach))) => { let q = m.apply(Point { x: (i % width) as f64 + 0.5, y: (i / width) as f64 + 0.5 }); - let c = sample(g, q); + let c = sample(g, reach, q); (rgb(c), c.alpha()) } (None, None) => continue, @@ -376,8 +376,22 @@ fn decode(bytes: &[u8], width: u32, height: u32) -> Option> { Some(reader.decode().ok()?.into_rgba8().into_raw()) } -/// The gradient's colour at unit-space point `q`. -fn sample(g: &Gradient, q: Point) -> Color { +/// For each pair of neighbouring stops (`k`, `k + 1`), the largest offset of stops `1..=k + 1`: +/// the first pair whose end offset a `t` doesn't pass is the first whose reach it doesn't pass, and +/// reaches never decrease, so [`sample`] finds it by binary search, sorted stops or not. +fn stop_reach(g: &Gradient) -> Vec { + g.stops + .iter() + .skip(1) + .scan(f64::NEG_INFINITY, |m, s| { + *m = m.max(s.offset); + Some(*m) + }) + .collect() +} + +/// The gradient's colour at unit-space point `q` (`reach`: [`stop_reach`] of `g`). +fn sample(g: &Gradient, reach: &[f64], q: Point) -> Color { let t = match g.kind { GradientKind::Linear => q.x, GradientKind::Radial => q.x.hypot(q.y), @@ -388,18 +402,16 @@ fn sample(g: &Gradient, q: Point) -> Color { if t <= first.offset { return first.color; } - for w in g.stops.windows(2) { - let [a, b] = [&w[0], &w[1]]; - if t <= b.offset { - let span = b.offset - a.offset; - let u = if span > 1e-9 { (t - a.offset) / span } else { 1.0 }; - // Affinity's midpoint bias (see `Stop::half_point`). - let k = 1.0 + 8.0 * (a.bias - 0.5).abs(); - let u = if a.bias >= 0.5 { u.powf(k) } else { 1.0 - (1.0 - u).powf(k) }; - let (x, y) = (rgb(a.color), rgb(b.color)); - let [r, g, bl] = [0, 1, 2].map(|i| x[i] + (y[i] - x[i]) * u); - return Color::Rgb { r, g, b: bl, a: a.color.alpha() + (b.color.alpha() - a.color.alpha()) * u }; - } + let k = reach.partition_point(|r| *r < t); + if let (Some(a), Some(b)) = (g.stops.get(k), g.stops.get(k + 1)) { + let span = b.offset - a.offset; + let u = if span > 1e-9 { (t - a.offset) / span } else { 1.0 }; + // Affinity's midpoint bias (see `Stop::half_point`). + let k = 1.0 + 8.0 * (a.bias - 0.5).abs(); + let u = if a.bias >= 0.5 { u.powf(k) } else { 1.0 - (1.0 - u).powf(k) }; + let (x, y) = (rgb(a.color), rgb(b.color)); + let [r, g, bl] = [0, 1, 2].map(|i| x[i] + (y[i] - x[i]) * u); + return Color::Rgb { r, g, b: bl, a: a.color.alpha() + (b.color.alpha() - a.color.alpha()) * u }; } last.color } diff --git a/crates/affinity/src/shapes.rs b/crates/affinity/src/shapes.rs index c5d1b31e0..a2503539f 100644 --- a/crates/affinity/src/shapes.rs +++ b/crates/affinity/src/shapes.rs @@ -13,8 +13,7 @@ const MAX_SIDES: i64 = 10_000; /// The last value of a tag: a shape changed from one kind to another keeps a stale first block. fn last<'a>(r: &'a Reader, id: ObjId, tag: &[u8; 4]) -> Option<&'a Value> { - let t = Tag::of(tag); - r.s.object(id)?.fields.iter().rev().find(|(k, _)| *k == t).map(|(_, v)| v) + r.s.object(id)?.get_last(Tag::of(tag)) } fn num(r: &Reader, id: ObjId, tag: &[u8; 4]) -> Option { @@ -51,12 +50,13 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) -> let class = r.s.class(shape)?; let sub = match &class.0.to_be_bytes() { b"ShNR" | b"ShRR" => { - let types = match last(r, shape, b"CTyp") { - Some(Value::Array(a)) => a.iter().map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(), + // Only the four corners' entries can matter: longer arrays are read no further. + let types: Vec> = match last(r, shape, b"CTyp") { + Some(Value::Array(a)) => a.iter().take(4).map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(), _ => Vec::new(), }; - let radii = match last(r, shape, b"ShCR") { - Some(Value::Floats(v)) => v.iter().map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(), + let radii: Vec = match last(r, shape, b"ShCR") { + Some(Value::Floats(v)) => v.iter().take(4).map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(), _ => Vec::new(), }; // Corners follow the first one (top left) unless they were unlocked; then each has diff --git a/crates/affinity/src/stream.rs b/crates/affinity/src/stream.rs index 7a3010251..9d31f35e7 100644 --- a/crates/affinity/src/stream.rs +++ b/crates/affinity/src/stream.rs @@ -73,11 +73,67 @@ pub struct Object { pub chain: Vec, /// Fields in stream order; positional fields have tag 0. pub fields: Vec<(Tag, Value)>, + /// For objects with many fields: each tag with the positions of its first and last field, + /// sorted by tag, so a lookup isn't a scan of every field (a shared object is looked up + /// again on every visit). Built by [`Object::index`]; empty for small objects. + index: Vec<(Tag, u32, u32)>, } +/// Objects with more fields than this get an [`Object::index`]; fewer are scanned. +const INDEXED_FIELDS: usize = 16; + impl Object { + pub fn new(class: Tag, kind: Kind, chain: Vec, fields: Vec<(Tag, Value)>) -> Self { + let mut o = Self { class, kind, chain, fields, index: Vec::new() }; + o.index(); + o + } + + /// (Re)build the tag index after `fields` changed. + fn index(&mut self) { + self.index.clear(); + if self.fields.len() <= INDEXED_FIELDS { + return; + } + let mut index: Vec<(Tag, u32, u32)> = Vec::new(); + if index.try_reserve_exact(self.fields.len()).is_err() { + return; + } + // Fields beyond u32 positions are impossible: a stream holds at most MAX_VALUES values. + index.extend(self.fields.iter().enumerate().filter_map(|(i, (t, _))| u32::try_from(i).ok().map(|i| (*t, i, i)))); + // Stable: equal tags stay in stream order, so the run of a tag starts at its first field. + index.sort_by_key(|(t, _, _)| *t); + index.dedup_by(|later, first| { + if later.0 == first.0 { + first.2 = later.2; + true + } else { + false + } + }); + index.shrink_to_fit(); + self.index = index; + } + + fn position(&self, tag: Tag, last: bool) -> Option { + if self.index.is_empty() { + let mut fields = self.fields.iter().enumerate(); + let hit = if last { fields.rev().find(|(_, (t, _))| *t == tag) } else { fields.find(|(_, (t, _))| *t == tag) }; + return hit.map(|(i, _)| i); + } + let at = self.index.binary_search_by_key(&tag, |(t, _, _)| *t).ok()?; + let (_, first, end) = self.index.get(at)?; + usize::try_from(if last { *end } else { *first }).ok() + } + + /// The first field tagged `tag`. pub fn get(&self, tag: Tag) -> Option<&Value> { - self.fields.iter().find(|(t, _)| *t == tag).map(|(_, v)| v) + self.fields.get(self.position(tag, false)?).map(|(_, v)| v) + } + + /// The last field tagged `tag` (a shape changed from one kind to another keeps a stale first one). + pub fn get_last(&self, tag: Tag) -> Option<&Value> { + self.fields.get(self.position(tag, true)?).map(|(_, v)| v) } } @@ -124,10 +180,11 @@ pub fn parse(bytes: &[u8]) -> Result { c.u32()?; } let mut p = Parser { c, objects: Vec::new(), shared: std::collections::HashMap::new(), depth: 0, values: 0 }; - let root = p.push(Object { class, kind: Kind::Root, chain: vec![class], fields: Vec::new() })?; + let root = p.push(Object::new(class, Kind::Root, vec![class], Vec::new()))?; let fields = p.fields(true, class)?; if let Some(o) = p.objects.get_mut(root) { o.fields = fields; + o.index(); } Ok(Stream { root, objects: p.objects }) } @@ -252,7 +309,7 @@ impl Parser<'_> { } 0x30 => { let fields = self.fields(false, class)?; - let id = self.push(Object { class, kind: Kind::Positional, chain: Vec::new(), fields })?; + let id = self.push(Object::new(class, Kind::Positional, Vec::new(), fields))?; Value::Obj(Some(id)) } 0x31 | 0x32 if !tagged => return Err(Error::Malformed("object without a field tag")), @@ -354,7 +411,7 @@ impl Parser<'_> { } }; let fields = self.fields(true, class)?; - let id = self.push(Object { class, kind: Kind::Inline, chain: vec![class], fields })?; + let id = self.push(Object::new(class, Kind::Inline, vec![class], fields))?; Ok(Value::Obj(Some(id))) } @@ -373,7 +430,7 @@ impl Parser<'_> { return Err(Error::Malformed("object defined twice")); } // Register before the fields: an object's own fields may refer back to it. - let id = self.push(Object { class: Tag(0), kind: Kind::Shared, chain: Vec::new(), fields: Vec::new() })?; + let id = self.push(Object::new(Tag(0), Kind::Shared, Vec::new(), Vec::new()))?; self.shared.insert(key, id); let mut chain = Vec::new(); let mut fields = Vec::new(); @@ -399,8 +456,36 @@ impl Parser<'_> { let class = chain.first().copied().unwrap_or(Tag(0)); fields.extend(self.fields(true, class)?); if let Some(o) = self.objects.get_mut(id) { - *o = Object { class, kind: Kind::Shared, chain, fields }; + *o = Object::new(class, Kind::Shared, chain, fields); } Ok(Value::Obj(Some(id))) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lookups_in_a_large_object_find_the_first_and_last_fields_of_a_tag() { + let tag = |i: u32| Tag(0x4100_0000 + i); + let mut fields: Vec<(Tag, Value)> = (0..1000).map(|i| (tag(i % 300), Value::UInt(u64::from(i)))).collect(); + fields.push((tag(7), Value::Str("last".into()))); + let o = Object::new(Tag::of(b"Grup"), Kind::Shared, Vec::new(), fields.clone()); + assert!(!o.index.is_empty()); + // The index has one entry per distinct tag, not per field. + assert_eq!(o.index.len(), 300); + for t in 0..300 { + let first = fields.iter().find(|(k, _)| *k == tag(t)).map(|(_, v)| v); + let last = fields.iter().rev().find(|(k, _)| *k == tag(t)).map(|(_, v)| v); + assert_eq!(o.get(tag(t)), first); + assert_eq!(o.get_last(tag(t)), last); + } + assert_eq!(o.get(tag(300)), None); + assert_eq!(o.get_last(tag(7)), Some(&Value::Str("last".into()))); + let small = Object::new(Tag::of(b"Grup"), Kind::Shared, Vec::new(), vec![(tag(1), Value::Bool(true)), (tag(1), Value::Bool(false))]); + assert!(small.index.is_empty()); + assert_eq!(small.get(tag(1)), Some(&Value::Bool(true))); + assert_eq!(small.get_last(tag(1)), Some(&Value::Bool(false))); + } +} diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs index eeb709d34..75b6b7b14 100644 --- a/crates/affinity/src/tests.rs +++ b/crates/affinity/src/tests.rs @@ -261,6 +261,34 @@ mod archive { assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_)))); } + #[test] + fn an_entry_that_fails_its_checksum_is_charged_once_and_not_decompressed_again() { + let d = doc(); + let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None); + let mut broken = file.clone(); + broken[90] ^= 0x55; + let mut a = Archive::open(&broken, Limits::default()).unwrap(); + let first = a.read("doc.dat").unwrap_err(); + // The work was done, so it counts against the import even though it failed. + assert_eq!(a.extracted(), d.len()); + assert_eq!(a.read("doc.dat").unwrap_err(), first); + assert_eq!(a.extracted(), d.len()); + // A budget that only covers one attempt is not exceeded by asking again. + let mut a = Archive::open(&broken, Limits { max_entry: usize::MAX, max_total: d.len() }).unwrap(); + assert!(a.read("doc.dat").is_err()); + assert_eq!(a.read("doc.dat").unwrap_err(), first); + } + + #[test] + fn the_table_index_has_a_budget_for_records_and_names() { + let d = doc(); + let long = "n".repeat(300); + let file = synth::container(&[("doc.dat", &d, Method::Stored), (&long, b"tile", Method::Stored)], None); + Archive::open_within(&file, Limits::default(), 2, 307).unwrap(); + assert_eq!(Archive::open_within(&file, Limits::default(), 1, 1 << 20).unwrap_err(), Error::Limit("too many archive entries")); + assert_eq!(Archive::open_within(&file, Limits::default(), 2, 306).unwrap_err(), Error::Limit("archive entry names too long")); + } + // Regression test for #1710: ruzstd 0.9.0 let a block decompress to more than its frame's // window allows (RUSTSEC-2026-0344), so a crafted entry made the decoder produce far more than // the format permits for a block. 0.9.1 refuses it. diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index 60e8a0234..093ce531a 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -781,10 +781,9 @@ pub fn unique_name(lib: &[Brush], base: &str) -> String { return base.to_string(); } // At most `lib.len()` names are taken, so one of the first `lib.len() + 1` candidates is free. - (2..=lib.len() + 2) - .map(|i| format!("{base} {i}")) - .find(|n| !lib.iter().any(|b| &b.name == n)) - .unwrap_or_else(|| format!("{base} {}", lib.len() + 2)) + // Each is looked up in a set: scanning the library per candidate is quadratic in its size. + let taken: std::collections::HashSet<&str> = lib.iter().map(|b| b.name.as_str()).collect(); + (2..=lib.len() + 2).map(|i| format!("{base} {i}")).find(|n| !taken.contains(n.as_str())).unwrap_or_else(|| format!("{base} {}", lib.len() + 2)) } // ---------- geometry ---------- @@ -849,9 +848,15 @@ fn draws_brushed(doc: &Document, def: Named<'_>, known: &mut Known) -> bool { }; // Without recursion, each one looked at once: they may use one another without end. let (mut seen, mut stack) = (std::collections::HashSet::from([key.clone()]), art(def)); + // A brush name the library lacks draws a plain stroke ([`node_pieces`]), which runs no brush. + let mut names: Option> = None; + let mut resolves = |n: &Node| { + let names = names.get_or_insert_with(|| library(doc).into_iter().map(|b| b.name).collect()); + n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.visible && s.brush.as_ref().is_some_and(|b| names.contains(b)))) + }; let mut answer = false; while let Some(n) = stack.pop() { - if has_brush(n) { + if has_brush(n) && resolves(n) { answer = true; break; } @@ -884,7 +889,16 @@ fn leave_out_brushed(doc: &Document, nodes: &mut Vec, known: &mut Known) { walk(doc, Arc::make_mut(c), known); } } - if n.extras().mask.is_some() + // A mask's art is drawn too. Its root is filtered like any other node: a symbol instance + // there that draws a brushed stroke would run the brush again from inside the mask, so the + // mask goes, as such an instance goes from the pieces and their children. + let rejected_root = matches!( + n.extras().mask.as_ref().map(|m| &m.art.kind), + Some(NodeKind::SymbolInstance { symbol, .. }) if draws_brushed(doc, (false, symbol.as_str()), known) + ); + if rejected_root { + n.extras_mut().mask = None; + } else if n.extras().mask.is_some() && let Some(mask) = n.extras_mut().mask.as_mut() { walk(doc, Arc::make_mut(&mut mask.art), known); diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 6e8113f14..7871b61af 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -1301,6 +1301,7 @@ fn pieces_leave_out_what_draws_a_brushed_stroke_by_name() { }; let art = Node::group(NodeId(0), [instance("plain"), instance("brushed"), painted("plain"), painted("brushed")].map(std::sync::Arc::new).into()); let b = Brush { name: "s".into(), kind: BrushKind::Scatter(Scatter { art, colorization: Colorization::None, ..Default::default() }) }; + store(&mut doc, std::slice::from_ref(&b)); let out = stroke_pieces(&doc, &b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); assert!(!out.is_empty()); for piece in &out { @@ -1316,6 +1317,46 @@ fn pieces_leave_out_what_draws_a_brushed_stroke_by_name() { } } +/// A symbol instance at the root of an opacity mask is filtered like one among the children: when +/// its symbol draws a brushed stroke the mask goes (it would run the brush again from inside the +/// mask, without end), and a mask whose root symbol is plain stays. +#[test] +fn pieces_leave_out_a_mask_whose_root_draws_a_brushed_stroke() { + use vectorcraft_doc::{OpacityMask, Symbol}; + let mut doc = Document::new(100.0, 100.0); + let mut brushed = rect_art(10.0, 4.0); + brushed.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(StrokeLayer { brush: Some("s".into()), ..stroke(1.0) })); + // A brush the library lacks draws a plain stroke: its mask stays. + let mut unresolved = rect_art(10.0, 4.0); + unresolved.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(StrokeLayer { brush: Some("missing".into()), ..stroke(1.0) })); + for (name, art) in [("plain", rect_art(10.0, 4.0)), ("brushed", brushed), ("unresolved", unresolved)] { + doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: std::sync::Arc::new(art) }); + } + let instance = |symbol: &str| Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: symbol.into(), xf: vectorcraft_geom::Affine::IDENTITY }); + let masked = |symbol: &str| { + let mut n = rect_art(10.0, 4.0); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(instance(symbol), true))); + n + }; + let art = Node::group(NodeId(0), [masked("plain"), masked("brushed"), masked("unresolved")].map(std::sync::Arc::new).into()); + let b = Brush { name: "s".into(), kind: BrushKind::Scatter(Scatter { art, colorization: Colorization::None, ..Default::default() }) }; + store(&mut doc, std::slice::from_ref(&b)); + let out = stroke_pieces(&doc, &b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); + assert!(!out.is_empty()); + for piece in &out { + let roots: Vec> = piece + .children() + .unwrap() + .iter() + .map(|c| match c.extras().mask.as_ref().map(|m| &m.art.kind) { + Some(NodeKind::SymbolInstance { symbol, .. }) => Some(symbol.as_str()), + _ => None, + }) + .collect(); + assert_eq!(roots, [Some("plain"), None, Some("unresolved")], "the plain and unresolved masks stay and the brushed one goes"); + } +} + /// Anchors a brushed stroke emitted, which is what the output budget counts. fn anchors(nodes: &[Node]) -> usize { nodes diff --git a/crates/brush/src/track.rs b/crates/brush/src/track.rs index 7d8799186..6c6524617 100644 --- a/crates/brush/src/track.rs +++ b/crates/brush/src/track.rs @@ -236,7 +236,8 @@ pub fn tracks(bp: &BezPath, tol: f64) -> Vec { return out; } let mut els = vec![]; - kurbo::flatten(bp.iter(), tol.max(1e-4), |el| { + // Guarded: kurbo works out every subdivision of a curve even when the callback drops them. + vectorcraft_geom::flatten(bp.iter(), tol.max(1e-4), |el| { if els.len() < MAX_FLATTENED { els.push(el); } diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs index ae0c170f3..c52f7f630 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -103,7 +103,7 @@ fn densified(sp: &SubPath, flat_tol: f64, max_du: f64) -> Option> { let mut bp = BezPath::new(); sp.to_bezpath_into(&mut bp); let mut pts = vec![]; - kurbo::flatten(bp.iter(), flat_tol.max(1e-4), |e| { + vectorcraft_geom::flatten(bp.iter(), flat_tol.max(1e-4), |e| { if let PathEl::MoveTo(q) | PathEl::LineTo(q) = e { pts.push(q); } diff --git a/crates/cad/src/import/build.rs b/crates/cad/src/import/build.rs index 6857c2a17..eb24859f1 100644 --- a/crates/cad/src/import/build.rs +++ b/crates/cad/src/import/build.rs @@ -23,10 +23,19 @@ const PT_PER_MM: f64 = 72.0 / 25.4; const THINNEST: f64 = 0.25; /// The most objects an import makes. const MAX_NODES: usize = 2_000_000; -/// The most block visits (one per insert copy) an import makes, whatever they produce. -const MAX_VISITS: usize = 10_000_000; +/// The most block work an import does, whatever it produces: one per insert copy drawn, and one +/// per item of the block looked at for it (art on hidden layers too). +const MAX_VISITS: usize = if cfg!(test) { 1_000_000 } else { 20_000_000 }; /// The most path elements an import makes (blocks drawn as art are copied for every placement). const MAX_ELEMENTS: usize = if cfg!(test) { 1_000_000 } else { 4_000_000 }; +/// The most dash lengths an import keeps (every dashed path holds its own copy of its pattern). +const MAX_DASH_VALUES: usize = if cfg!(test) { 1_000 } else { 4_000_000 }; +/// The most bytes of text, font names and symbol names an import copies into objects (a block's +/// text and name are copied for every placement). +const MAX_TEXT_BYTES: usize = if cfg!(test) { 64 << 10 } else { 64 << 20 }; +/// The longest font family a text style may name; a longer one is not a font, and is set in the +/// default. +const MAX_FAMILY_BYTES: usize = 256; /// How far from the origin art may lie (points): the canvas. pub(crate) const MAX_EXTENT: f64 = 4.0e6; @@ -34,6 +43,9 @@ const NESTED: &str = "blocks nested more than 8 deep, or inside themselves, are const XREFS: &str = "blocks referencing other drawings (external references) are left out"; const TOO_MANY: &str = "the drawing has more than 2,000,000 objects: the rest are left out"; const TOO_MANY_POINTS: &str = "the drawing has more than 4,000,000 path points: the rest are left out"; +const TOO_MUCH_BLOCK_WORK: &str = "the drawing's blocks take too much work to place: the rest are left out"; +const TOO_MANY_DASHES: &str = "the drawing has more dashed lines than can be kept: the rest are drawn solid"; +const TOO_MUCH_TEXT: &str = "the drawing has more than 64 MB of text and block names: the rest are left out"; /// What a block's art takes from the insert that places it: its colour, lineweight, linetype /// and opacity where the art says "by block", and its layer for art on layer 0. @@ -71,6 +83,9 @@ pub(crate) struct Build<'d, 'a> { visits: usize, /// Path elements made so far. elements: usize, + /// Dash lengths and text bytes kept so far. + dash_values: usize, + text_bytes: usize, warnings: Vec, } @@ -128,6 +143,8 @@ impl<'d, 'a> Build<'d, 'a> { nodes: 0, visits: 0, elements: 0, + dash_values: 0, + text_bytes: 0, warnings: vec![], } } @@ -138,6 +155,20 @@ impl<'d, 'a> Build<'d, 'a> { } } + /// Take `bytes` of text from [`MAX_TEXT_BYTES`]; false (with a warning) when they don't fit. + fn charge_text(&mut self, bytes: usize) -> bool { + match self.text_bytes.checked_add(bytes).filter(|t| *t <= MAX_TEXT_BYTES) { + Some(t) => { + self.text_bytes = t; + true + } + None => { + self.warn(TOO_MUCH_TEXT); + false + } + } + } + /// Import `layout`; `natural` and `ratio` are points per drawing unit in the drawing's own /// unit and at the scale asked for (fitting computes its own). pub fn run(mut self, layout: &LayoutDef, natural: f64, ratio: f64) -> Result<(Document, Vec), String> { @@ -469,6 +500,9 @@ impl<'d, 'a> Build<'d, 'a> { super::curve::bounds(&bp)?; let paint = Paint::solid(rgb_color(self.rgb(p, inh))); let scale = map.determinant().abs().sqrt(); + let path = PathData::from_bezpath(&bp); + // A compound path's loops each keep a copy of its appearance, beside its own. + let copies = if path.subpaths.len() > 1 { path.subpaths.len() + 1 } else { 1 }; let appearance = if fill { Appearance::basic(paint, Paint::None, 0.0) } else { @@ -484,15 +518,20 @@ impl<'d, 'a> Build<'d, 'a> { if let Some(AppearanceItem::Stroke(s)) = ap.items.last_mut() && let Some((dash, dots)) = self.dash(&self.linetype(p, inh), p, scale) { - s.dash = Some(dash); - if dots { - s.cap = LineCap::Round; + match dash.pattern.len().checked_mul(copies).and_then(|v| self.dash_values.checked_add(v)).filter(|n| *n <= MAX_DASH_VALUES) { + Some(n) => { + self.dash_values = n; + s.dash = Some(dash); + if dots { + s.cap = LineCap::Round; + } + } + None => self.warn(TOO_MANY_DASHES), } } ap }; let rule = if fill { FillRule::EvenOdd } else { FillRule::NonZero }; - let path = PathData::from_bezpath(&bp); let kind = |path: PathData| NodeKind::Path { path, rule, live: None, clipping: false, guide: false }; let mut n = if path.subpaths.len() > 1 { // Several loops (a hatch with islands) paint as one compound path. Each loop is an @@ -527,10 +566,15 @@ impl<'d, 'a> Build<'d, 'a> { if !(s > 1e-12 && s.is_finite()) { return None; } - let family = t.family.clone().or_else(|| self.d.styles.get(&t.style).filter(|f| !f.is_empty()).cloned()); + let d = self.d; + let family = t.family.as_deref().or_else(|| d.styles.get(&t.style).map(String::as_str).filter(|f| !f.is_empty())); + let family = family.filter(|f| f.len() <= MAX_FAMILY_BYTES); + if !self.charge_text(t.text.len().saturating_add(family.map_or(0, str::len))) { + return None; + } let default = CharStyle::default(); let style = CharStyle { - font_family: family.unwrap_or(default.font_family.clone()), + font_family: family.map_or_else(|| default.font_family.clone(), str::to_string), size: t.height / CAP_HEIGHT * s, h_scale: t.width * 100.0, leading: t.leading.map(|l| l * s), @@ -558,6 +602,13 @@ impl<'d, 'a> Build<'d, 'a> { for cell in &ins.cells { let node = match &symbol { Some(name) => { + if self.nodes >= MAX_NODES { + self.warn(TOO_MANY); + break; + } + if !self.charge_text(name.len()) { + break; + } let xf = map * *cell * self.symbol_map.inverse(); self.nodes += 1; Some(Node::new(self.doc.alloc_id(), NodeKind::SymbolInstance { symbol: name.clone(), xf })) @@ -581,14 +632,19 @@ impl<'d, 'a> Build<'d, 'a> { /// The art of block `name` drawn by `map`, as one object. fn art(&mut self, name: &str, map: Affine, inh: &Inherit, depth: u32) -> Option { - self.visits += 1; - if self.visits > MAX_VISITS { - if self.visits == MAX_VISITS + 1 { - self.warn(TOO_MANY); - } + if self.visits >= MAX_VISITS { + self.warn(TOO_MUCH_BLOCK_WORK); return None; } - let items = self.block(name)?; + let items = self.block(name); + // Each placement looks at every item of the block, drawn or not (art on a hidden layer + // makes nothing, so the node budget doesn't bound it). + self.visits = self.visits.saturating_add(1 + items.as_ref().map_or(0, |i| i.len())); + if self.visits > MAX_VISITS { + self.warn(TOO_MUCH_BLOCK_WORK); + return None; + } + let items = items?; if !self.enter(name, depth) { return None; } @@ -618,12 +674,16 @@ impl<'d, 'a> Build<'d, 'a> { let base = self.d.blocks.get(name).map_or(Point::ZERO, |b| b.base); let map = self.symbol_map * Affine::translate(-base.to_vec2()); let art = self.art(name, map, &key.1, depth); - let made = art.map(|art| { + let made = art.and_then(|art| { let label = self.d.blocks.get(name).map_or(name, |b| b.name.as_str()); + // The symbol keeps its name: text, as its instances' copies of it are. + if !self.charge_text(label.len()) { + return None; + } let symbol = unique(label, &mut self.next_suffix, &self.symbol_names); self.symbol_names.insert(symbol.clone()); self.doc.symbols.push(Symbol { dynamic: false, name: symbol.clone(), art: Arc::new(art) }); - symbol + Some(symbol) }); self.symbols.insert(key, made.clone()); made diff --git a/crates/cad/src/import/tests.rs b/crates/cad/src/import/tests.rs index 6bd3d1c10..d28a48553 100644 --- a/crates/cad/src/import/tests.rs +++ b/crates/cad/src/import/tests.rs @@ -814,7 +814,7 @@ fn nested_array_inserts_of_nothing_stop_at_the_work_budget() { blocks.extend(block("*C", None)); let top = [(0, "INSERT"), (2, "*A"), (10, "0"), (20, "0"), (70, "300"), (44, "1")]; let r = open(&drawing(&[], &[], &blocks, &top), &points()); - assert!(r.warnings.iter().any(|w| w.contains("more than")), "{:?}", r.warnings); + assert!(r.warnings.iter().any(|w| w.contains("too much work")), "{:?}", r.warnings); } #[test] @@ -1177,3 +1177,173 @@ fn many_array_inserts_of_a_missing_block_stay_bounded() { assert!(total <= 2_000_000 + 3000, "total cells materialised: {total}"); assert!(conv.warnings.iter().any(|w| w.contains("array inserts")), "{:?}", conv.warnings); } + +#[test] +fn dashed_lines_past_the_dash_budget_are_drawn_solid() { + // 600 lines of a two-length linetype: 1,200 dash lengths, past the 1,000 tests import with. + let tables = [(0, "TABLE"), (2, "LTYPE"), (0, "LTYPE"), (2, "DASHED"), (73, "2"), (40, "15"), (49, "10"), (49, "-5"), (0, "ENDTAB")]; + let line = [(0, "LINE"), (6, "DASHED"), (10, "0"), (20, "0"), (11, "100"), (21, "0")]; + let e: Vec<(i32, &str)> = (0..600).flat_map(|_| line).collect(); + let r = open(&drawing(&[], &tables, &[], &e), &points()); + let (mut dashed, mut solid, mut kept) = (0, 0, 0); + r.document.walk(|n| { + if let NodeKind::Path { .. } = n.kind { + match n.appearance.stroke().and_then(|s| s.dash.as_ref()) { + Some(d) => { + dashed += 1; + kept += d.pattern.len(); + } + None => solid += 1, + } + } + }); + assert_eq!(dashed + solid, 600); + assert!(dashed > 0 && solid > 0, "{dashed} dashed, {solid} solid"); + assert!(kept <= 1_000, "{kept} dash lengths kept"); + assert!(r.warnings.iter().any(|w| w.contains("drawn solid")), "{:?}", r.warnings); +} + +/// The bytes of text in the document's text objects. +fn text_bytes(doc: &Document) -> usize { + let mut bytes = 0; + doc.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + bytes += t.runs.iter().map(|r| r.text.len()).sum::(); + } + }); + bytes +} + +#[test] +fn text_copied_into_array_cells_stops_at_the_text_budget() { + // 1,000 characters of text in an anonymous block placed 10 × 10: 100 KB of copies, past the + // 64 KB tests import with. + let long = "x".repeat(1000); + let blocks = + [(0, "BLOCK"), (2, "*U1"), (70, "1"), (10, "0"), (20, "0"), (0, "TEXT"), (10, "0"), (20, "0"), (40, "1"), (1, long.as_str()), (0, "ENDBLK")]; + let insert = [(0, "INSERT"), (2, "*U1"), (10, "0"), (20, "0"), (70, "10"), (71, "10"), (44, "1"), (45, "1")]; + let r = open(&drawing(&[], &[], &blocks, &insert), &points()); + let bytes = text_bytes(&r.document); + assert!(bytes > 0, "some copies are kept"); + assert!(bytes <= 64 << 10, "{bytes} bytes of text"); + assert!(r.warnings.iter().any(|w| w.contains("64 MB of text")), "{:?}", r.warnings); +} + +#[test] +fn a_text_style_naming_an_overlong_font_family_takes_the_default() { + let family = "F".repeat(10_000); + let tables = [(0, "TABLE"), (2, "STYLE"), (0, "STYLE"), (2, "Big"), (3, "x.ttf"), (1001, "ACAD"), (1000, family.as_str()), (0, "ENDTAB")]; + let e = [(0, "TEXT"), (10, "0"), (20, "0"), (40, "1"), (1, "Hi"), (7, "Big")]; + let doc = open(&drawing(&[], &tables, &[], &e), &points()).document; + let mut families = vec![]; + doc.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + families.extend(t.runs.iter().map(|r| r.style.font_family.clone())); + } + }); + assert_eq!(families, [vectorcraft_doc::CharStyle::default().font_family]); +} + +#[test] +fn symbol_instances_of_a_long_block_name_stop_at_the_text_budget() { + // A 1,000-byte block name placed 10 × 10: each instance keeps its own copy of the name. + let name = "N".repeat(1000); + let blocks = [(0, "BLOCK"), (2, name.as_str()), (10, "0"), (20, "0"), (0, "LINE"), (10, "0"), (20, "0"), (11, "1"), (21, "0"), (0, "ENDBLK")]; + let insert = [(0, "INSERT"), (2, name.as_str()), (10, "0"), (20, "0"), (70, "10"), (71, "10"), (44, "2"), (45, "2")]; + let r = open(&drawing(&[], &[], &blocks, &insert), &points()); + let mut instances = 0; + r.document.walk(|n| { + if let NodeKind::SymbolInstance { .. } = n.kind { + instances += 1; + } + }); + assert!(instances > 0 && instances < 100, "{instances} instances"); + assert!(r.warnings.iter().any(|w| w.contains("64 MB of text")), "{:?}", r.warnings); +} + +#[test] +fn arrays_of_blocks_of_hidden_art_stop_at_the_work_budget() { + // 500 lines on a hidden layer in an anonymous block placed 50 × 50: they make nothing, but + // each placement looks at all of them (1,250,000 items, past the 1,000,000 tests import with). + let tables = [(0, "TABLE"), (2, "LAYER"), (0, "LAYER"), (2, "Off"), (70, "0"), (62, "-1"), (0, "ENDTAB")]; + let mut blocks = vec![(0, "BLOCK"), (2, "*U1"), (70, "1"), (10, "0"), (20, "0")]; + for _ in 0..500 { + blocks.extend([(0, "LINE"), (8, "Off"), (10, "0"), (20, "0"), (11, "1"), (21, "0")]); + } + blocks.push((0, "ENDBLK")); + let insert = [(0, "INSERT"), (2, "*U1"), (10, "0"), (20, "0"), (70, "50"), (71, "50"), (44, "1"), (45, "1")]; + let r = open(&drawing(&[], &tables, &blocks, &insert), &points()); + assert!(r.warnings.iter().any(|w| w.contains("too much work")), "{:?}", r.warnings); +} + +#[test] +fn dashed_compound_paths_charge_every_copy_of_their_pattern() { + // 100 pattern hatches of 9 loops in a two-length linetype: each keeps its dashes on the + // compound path and on each loop, 2,000 dash lengths, past the 1,000 tests import with. + let tables = [(0, "TABLE"), (2, "LTYPE"), (0, "LTYPE"), (2, "DASHED"), (73, "2"), (40, "15"), (49, "10"), (49, "-5"), (0, "ENDTAB")]; + let corners = ["0", "10", "20", "30", "40", "50", "60", "70", "80", "90"]; + let mut hatch = vec![(0, "HATCH"), (6, "DASHED"), (10, "0"), (20, "0"), (30, "0"), (2, "ANSI31"), (70, "0"), (71, "0"), (91, "9")]; + for i in 0..9 { + let (a, b) = (corners[i], corners[i + 1]); + hatch.extend([ + (92, "2"), + (72, "0"), + (73, "1"), + (93, "4"), + (10, a), + (20, "0"), + (10, b), + (20, "0"), + (10, b), + (20, "5"), + (10, a), + (20, "5"), + (97, "0"), + ]); + } + hatch.extend([(75, "0"), (76, "1"), (98, "0")]); + let e: Vec<(i32, &str)> = (0..100).flat_map(|_| hatch.iter().copied()).collect(); + let r = open(&drawing(&[], &tables, &[], &e), &points()); + let (mut compounds, mut kept) = (0, 0); + r.document.walk(|n| { + compounds += usize::from(matches!(n.kind, NodeKind::Compound { .. })); + kept += n.appearance.stroke().and_then(|s| s.dash.as_ref()).map_or(0, |d| d.pattern.len()); + }); + assert_eq!(compounds, 100, "{:?}", r.warnings); + assert!(kept > 0 && kept <= 1_000, "{kept} dash lengths kept"); + assert!(r.warnings.iter().any(|w| w.contains("drawn solid")), "{:?}", r.warnings); +} + +#[test] +fn symbols_of_a_long_block_name_stop_at_the_text_budget() { + // A 1,000-byte block name inserted in 100 colours its art takes: a symbol per colour, each + // keeping its own copy of the name, past the 64 KB tests import with. + let name = "N".repeat(1000); + let blocks = [ + (0, "BLOCK"), + (2, name.as_str()), + (10, "0"), + (20, "0"), + (0, "LINE"), + (8, "0"), + (62, "0"), + (10, "0"), + (20, "0"), + (11, "1"), + (21, "0"), + (0, "ENDBLK"), + ]; + let colours: Vec = (1..=100).map(|c| c.to_string()).collect(); + let inserts: Vec<(i32, &str)> = + colours.iter().flat_map(|c| [(0, "INSERT"), (2, name.as_str()), (62, c.as_str()), (10, "0"), (20, "0")]).collect(); + let r = open(&drawing(&[], &[], &blocks, &inserts), &points()); + let mut bytes: usize = r.document.symbols.iter().map(|s| s.name.len()).sum(); + r.document.walk(|n| { + if let NodeKind::SymbolInstance { symbol, .. } = &n.kind { + bytes += symbol.len(); + } + }); + assert!(!r.document.symbols.is_empty()); + assert!(bytes <= 64 << 10, "{bytes} bytes of symbol names"); + assert!(r.warnings.iter().any(|w| w.contains("64 MB of text")), "{:?}", r.warnings); +} diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index b7aeb5c6c..72fa4f7b3 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -1,7 +1,7 @@ //! The document walk: each visible object becomes DXF entities on its layer's DXF layer, in paint //! order. Fills are their outlines then solid hatches (the outlines alone in R12), strokes -//! polylines and cubic splines with a lineweight and dashes, or filled outlines where the look -//! needs them. +//! polylines (with bulges for circular arcs) and cubic splines with a lineweight and dashes, or +//! filled outlines where the look needs them. use std::collections::HashMap; use std::io::Cursor; @@ -66,6 +66,8 @@ struct CircularSub { centre: Point, radius: f64, samples: Vec, + /// The signed angle (radians) the curve turns through about `centre` in document space. + turn: f64, } fn cubic_point(p0: Point, p1: Point, p2: Point, p3: Point, t: f64) -> Point { @@ -122,7 +124,12 @@ fn circular_subpath(sub: &Sub) -> Option { if !centre.x.is_finite() || !centre.y.is_finite() || !radius.is_finite() || radius <= 1e-9 { return None; } - let tolerance = (radius * 5e-4).max(1e-6); + // The tolerance is a fraction of the smaller of the radius and the curve's extent, so the large + // radius of a shallow curve does not loosen it. + let (lo, hi) = samples + .iter() + .fold((samples[0], samples[0]), |(lo, hi), p| (Point::new(lo.x.min(p.x), lo.y.min(p.y)), Point::new(hi.x.max(p.x), hi.y.max(p.y)))); + let tolerance = (radius.min(lo.distance(hi)) * 5e-4).max(1e-6); if samples.iter().any(|p| (centre.distance(*p) - radius).abs() > tolerance) { return None; } @@ -181,7 +188,76 @@ fn circular_subpath(sub: &Sub) -> Option { } else if total_turn.abs() < 1e-6 || total_turn.abs() >= std::f64::consts::TAU - 1e-3 { return None; } - Some(CircularSub { centre, radius, samples }) + Some(CircularSub { centre, radius, samples, turn: total_turn }) +} + +/// The points of the circle about `centre` of `radius` furthest left, right, up and down that lie on +/// its arc from `from` through `turn` radians, for the drawing extents. +fn arc_extrema(centre: Point, radius: f64, from: Point, turn: f64) -> Vec { + let start = (from.y - centre.y).atan2(from.x - centre.x); + (0..4) + .map(|k| f64::from(k) * std::f64::consts::FRAC_PI_2) + .filter(|a| ((a - start) * turn.signum()).rem_euclid(std::f64::consts::TAU) <= turn.abs()) + .map(|a| Point::new(centre.x + radius * a.cos(), centre.y + radius * a.sin())) + .collect() +} + +/// The most cubics one bulge arc is fitted to; the cubics after them start the next arc. +const MAX_ARC_RUN: usize = 64; + +/// A subpath of straight segments and circular arcs as polyline vertices, in document space. +struct BulgedSub { + /// The anchors, without a closed subpath's repeated start. + pts: Vec, + /// For each anchor, the angle (radians, document space) the segment that starts there turns + /// through: 0 for a straight segment. + turns: Vec, + /// The arcs' extreme points ([`arc_extrema`]), for the drawing extents. + extrema: Vec, +} + +/// Fit a subpath with curves to straight segments and circular arcs: each run of cubics on one +/// circle, turning one way and less than a whole turn, becomes one arc ([`circular_subpath`]'s +/// tolerance). `None` when the subpath has no curves or a curve that is not a circular arc. +fn bulged_subpath(sub: &Sub) -> Option { + if !sub.segs.iter().any(|s| matches!(s, Seg::Cubic(..))) { + return None; + } + let mut pts = vec![sub.start]; + let mut turns = vec![]; + let mut extrema = vec![]; + let mut from = sub.start; + let mut i = 0; + while let Some(seg) = sub.segs.get(i) { + if let Seg::Line(p) = *seg { + turns.push(0.0); + pts.push(p); + from = p; + i += 1; + continue; + } + let mut best = None; + let mut j = i; + while j < sub.segs.len() && j - i < MAX_ARC_RUN && matches!(sub.segs[j], Seg::Cubic(..)) { + let run = Sub { start: from, segs: sub.segs[i..=j].to_vec(), closed: false }; + let Some(arc) = circular_subpath(&run) else { break }; + best = Some((j, arc)); + j += 1; + } + let (j, arc) = best?; + turns.push(arc.turn); + extrema.extend(arc_extrema(arc.centre, arc.radius, from, arc.turn)); + from = sub.segs[j].end(); + pts.push(from); + i = j + 1; + } + if sub.closed && pts.last().is_some_and(|p| p.distance(sub.start) <= 1e-9) { + pts.pop(); + } else { + // The segment from the last anchor: the straight closing edge, or none for an open subpath. + turns.push(0.0); + } + (pts.len() >= 2).then_some(BulgedSub { pts, turns, extrema }) } /// A closed subpath that is the four-cubic circle under an affine map: an ellipse, or a circle @@ -271,7 +347,11 @@ const MAX_ENTITIES: u32 = 10_000_000; /// The largest x or y a path may have to be flattened, and the most points its polygons keep. const MAX_COORD: f64 = 1e9; -const MAX_POINTS: usize = 4_000_000; +const MAX_POINTS: usize = if cfg!(test) { 100_000 } else { 4_000_000 }; +/// The most segments flattening one path makes, kept or not (repeats of a point are dropped). +const MAX_FLATTEN_SEGMENTS: usize = 4 * MAX_POINTS; + +const SYMBOLS_LEFT_OUT: &str = "symbols placed inside symbols hold too many objects for DXF: the instances past that were left out"; /// Do the dashes of `d` start at the start of their pattern, as a linetype's do? A pattern of /// odd length repeats twice in its linetype. @@ -296,14 +376,19 @@ fn polygons(bp: &BezPath) -> Vec> { } let mut out: Vec> = vec![]; let mut cur: Vec = vec![]; - let mut points = 0usize; - let mut push = |p: Point, cur: &mut Vec| { - if points < MAX_POINTS && cur.last().is_none_or(|q| q.distance(p) > 1e-9) { - points += 1; + // Points kept, and segments made: once either budget is spent, no further curve is flattened. + let points = std::cell::Cell::new(0usize); + let segments = std::cell::Cell::new(0usize); + let push = |p: Point, cur: &mut Vec| { + segments.set(segments.get() + 1); + if points.get() < MAX_POINTS && cur.last().is_none_or(|q| q.distance(p) > 1e-9) { + points.set(points.get() + 1); cur.push(p); } }; - kurbo::flatten(bp.iter(), FLATNESS, |el| match el { + let elements = bp.iter().take_while(|_| points.get() < MAX_POINTS && segments.get() < MAX_FLATTEN_SEGMENTS); + // Each curve is flattened into a bounded number of segments, however large it is. + vectorcraft_geom::flatten(elements, FLATNESS, |el| match el { PathEl::MoveTo(p) => { out.push(std::mem::take(&mut cur)); push(p, &mut cur); @@ -412,6 +497,9 @@ pub(crate) struct Scene<'a> { opacity: f32, /// Symbols and brush art being written inside one another. nest: u32, + /// Objects the symbol instances written held, together + /// ([`vectorcraft_doc::MAX_SYMBOL_NODES`]). + symbol_nodes: u64, } impl<'a> Scene<'a> { @@ -441,6 +529,8 @@ impl<'a> Scene<'a> { extents: None, opacity: 1.0, nest: 0, + // Tests spend all but a million of it up front. + symbol_nodes: if cfg!(test) { vectorcraft_doc::MAX_SYMBOL_NODES - 1_000_000 } else { 0 }, } } @@ -542,13 +632,36 @@ impl<'a> Scene<'a> { NodeKind::Text(t) => self.text(n, t), NodeKind::Image(im) => self.image(im), NodeKind::SymbolInstance { symbol, xf } => { - if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol) + let doc = self.doc; + if self.symbol_nodes >= vectorcraft_doc::MAX_SYMBOL_NODES { + // Spent: no more instances are looked at, so what's left costs nothing. + self.warn(SYMBOLS_LEFT_OUT); + } else if let Some(sym) = doc.symbols.iter().find(|s| &s.name == symbol) && self.nest < MAX_NEST - && let Some(art) = self.doc.place_symbol_art_unclipped(symbol, &vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n), *xf) { - self.nest += 1; - self.node(&art, true); - self.nest -= 1; + // Symbols holding instances of symbols are written in full for each instance, + // empty or not: every instance's art counts against one budget for the export. + let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); + let count = art.count_with_masks() as u64; + // Counted as `place_symbol_art_unclipped` places it: no clip groups. + let most = doc.symbol_slice_map(symbol, *xf).map_or(count, |m| m.most_placed_unclipped(&art, doc)); + let placed = vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, most, || { + let placed = doc.place_symbol_art_unclipped(symbol, &art, *xf); + let n = placed.as_ref().map_or(0, |a| a.count_with_masks() as u64); + (placed, n) + }); + match placed { + Some(Some(art)) => { + self.nest += 1; + self.node(&art, true); + self.nest -= 1; + } + Some(None) => {} + None => { + self.symbol_nodes = vectorcraft_doc::MAX_SYMBOL_NODES; + self.warn(SYMBOLS_LEFT_OUT); + } + } } } // Live blends, envelopes, meshes and repeats are written as their evaluated art. @@ -809,7 +922,9 @@ impl<'a> Scene<'a> { } /// Path `bp` as lines: a line for an open subpath of one straight segment, polylines for - /// other straight subpaths (and for curves in R12, flattened), cubic splines for curved ones. + /// other straight subpaths, circles, arcs and ellipses where the curves are one, polylines + /// with bulges for subpaths of straight segments and circular arcs, and cubic splines for + /// other curved ones (flattened polylines in R12). /// `width`: the stroke weight, which DXF before 2000 gives polylines as a width. fn lines(&mut self, bp: &BezPath, style: &EntityStyle, width: f64) { let v = self.o.version; @@ -826,7 +941,7 @@ impl<'a> Scene<'a> { // A LINE has no width, so a stroke before 2000 keeps its polyline width. match (sub.closed, width, pts.as_slice()) { (false, None, &[a, b]) => self.line(a, b, style), - _ => self.polyline(&pts, sub.closed, style, width), + _ => self.polyline(&pts, &[], sub.closed, style, width), } } else if (v.handles() || width.is_none()) && self.circular(&sub, style) { // Native circles/arcs are preferred by CAD/CAM software. In R12, however, a @@ -834,6 +949,12 @@ impl<'a> Scene<'a> { // its width. Non-circular curves continue through the existing fallback. } else if v.handles() && self.elliptical(&sub, style) { // R13 and later write an ellipse as a native ELLIPSE. + } else if let Some(b) = bulged_subpath(&sub) { + // Straight segments and circular arcs: one polyline whose bulges draw the arcs. + b.extrema.iter().for_each(|p| _ = self.pt(*p)); + let flip = if self.to_dxf.determinant() < 0.0 { -1.0 } else { 1.0 }; + let bulges: Vec = b.turns.iter().map(|t| flip * (t / 4.0).tan()).collect(); + self.polyline(&b.pts, &bulges, sub.closed, style, width); } else if v.handles() { self.spline(&sub, style); } else { @@ -846,7 +967,7 @@ impl<'a> Scene<'a> { } } for pts in polygons(&path) { - self.polyline(&pts, sub.closed, style, width); + self.polyline(&pts, &[], sub.closed, style, width); } } } @@ -863,8 +984,9 @@ impl<'a> Scene<'a> { } /// A polyline through `pts` (document space): lightweight from R14, else a polyline with a - /// vertex entity per point. - fn polyline(&mut self, pts: &[Point], closed: bool, style: &EntityStyle, width: Option) { + /// vertex entity per point. `bulges`: the bulge (group 42) of the segment that starts at each + /// point, in drawing space; empty for straight segments only. + fn polyline(&mut self, pts: &[Point], bulges: &[f64], closed: bool, style: &EntityStyle, width: Option) { if pts.len() < 2 { return; } @@ -880,8 +1002,11 @@ impl<'a> Scene<'a> { if let Some(w) = width { self.e.num(43, w); } - for p in &pts { + for (i, p) in pts.iter().enumerate() { self.e.xy(10, p.x, p.y); + if let Some(&b) = bulges.get(i).filter(|b| **b != 0.0) { + self.e.num(42, b); + } } return; } @@ -893,13 +1018,16 @@ impl<'a> Scene<'a> { self.e.num(40, w); self.e.num(41, w); } - for p in &pts { + for (i, p) in pts.iter().enumerate() { let vh = self.handle(); self.e.entity("VERTEX", vh, h, &layer, style, "AcDbVertex"); if self.o.version.handles() { self.e.raw(100, "AcDb2dVertex"); } self.e.xyz(10, p.x, p.y); + if let Some(&b) = bulges.get(i).filter(|b| **b != 0.0) { + self.e.num(42, b); + } } let end = self.handle(); self.e.entity("SEQEND", end, h, &layer, style, ""); @@ -923,8 +1051,8 @@ impl<'a> Scene<'a> { return false; } - // Count sampled points in the drawing extents. For a full circle include all cardinal - // points exactly; for an arc, the dense samples conservatively cover its visible span. + // Count the curve's extreme points in the drawing extents: all four cardinal points of a + // full circle; an arc's ends and the cardinal points on it. if sub.closed { let r = circle.radius; for p in [ @@ -936,8 +1064,8 @@ impl<'a> Scene<'a> { self.pt(p); } } else { - for p in &circle.samples { - self.pt(*p); + for p in [sub.start, last].into_iter().chain(arc_extrema(circle.centre, circle.radius, sub.start, circle.turn)) { + self.pt(p); } } @@ -1262,3 +1390,47 @@ fn first_solid(n: &Node) -> Option { }); own.or_else(|| n.children().into_iter().flatten().find_map(|c| first_solid(c))) } + +#[cfg(test)] +mod budget_tests { + use super::*; + + #[test] + fn flattening_a_path_of_huge_curves_stops_at_its_budget() { + // Each curve, a hundred million points across, flattens to thousands of segments at + // FLATNESS; a small square after them is past the budget and left out. + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + for i in 0..20_000 { + let y = if i % 2 == 0 { 1e8 } else { -1e8 }; + bp.curve_to((1e8, y), (-1e8, y), (0.0, 0.0)); + } + bp.move_to((10.0, 10.0)); + bp.line_to((20.0, 10.0)); + bp.line_to((20.0, 20.0)); + bp.close_path(); + let polys = polygons(&bp); + let points: usize = polys.iter().map(Vec::len).sum(); + assert!(points <= MAX_POINTS, "{points} points"); + assert!(!polys.iter().any(|p| p.contains(&Point::new(20.0, 20.0))), "the square past the budget is left out"); + } + + #[test] + fn flattening_a_path_of_repeated_points_stops_at_its_segment_budget() { + // Repeats of a point are dropped, so these keep one point but still count as segments: + // the segment budget, not the point budget, leaves out the square after them. + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + for _ in 0..MAX_FLATTEN_SEGMENTS { + bp.line_to((0.0, 0.0)); + } + bp.move_to((10.0, 10.0)); + bp.line_to((20.0, 10.0)); + bp.line_to((20.0, 20.0)); + bp.close_path(); + let polys = polygons(&bp); + let points: usize = polys.iter().map(Vec::len).sum(); + assert!(points < MAX_POINTS, "{points} points"); + assert!(!polys.iter().any(|p| p.contains(&Point::new(20.0, 20.0))), "the square past the budget is left out"); + } +} diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index f1b537b70..d99858511 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -275,6 +275,197 @@ fn circles_and_circular_arcs_are_native_dxf_entities() { } } +/// A cubic arc about `c` of radius `r` from `deg0` to `deg1` (degrees, y down), from the current +/// point, which is the arc's start. +fn arc_cubic(b: &mut vectorcraft_geom::BezPath, c: Point, r: f64, deg0: f64, deg1: f64) { + let (a0, a1) = (deg0.to_radians(), deg1.to_radians()); + let k = 4.0 / 3.0 * ((a1 - a0) / 4.0).tan() * r; + let (p0, p3) = (Point::new(c.x + r * a0.cos(), c.y + r * a0.sin()), Point::new(c.x + r * a1.cos(), c.y + r * a1.sin())); + b.curve_to(Point::new(p0.x - k * a0.sin(), p0.y + k * a0.cos()), Point::new(p3.x + k * a1.sin(), p3.y - k * a1.cos()), p3); +} + +/// A slot (two lines, two half circles) about y = 50, a D shape (a half circle closed by its +/// diameter) and a rounded rectangle with 10 pt corners. +fn lines_and_arcs() -> [PathData; 3] { + let mut slot = vectorcraft_geom::BezPath::new(); + slot.move_to((60.0, 30.0)); + slot.line_to((140.0, 30.0)); + arc_cubic(&mut slot, Point::new(140.0, 50.0), 20.0, -90.0, 0.0); + arc_cubic(&mut slot, Point::new(140.0, 50.0), 20.0, 0.0, 90.0); + slot.line_to((60.0, 70.0)); + arc_cubic(&mut slot, Point::new(60.0, 50.0), 20.0, 90.0, 180.0); + arc_cubic(&mut slot, Point::new(60.0, 50.0), 20.0, 180.0, 270.0); + slot.close_path(); + let mut d = vectorcraft_geom::BezPath::new(); + d.move_to((150.0, 20.0)); + arc_cubic(&mut d, Point::new(150.0, 50.0), 30.0, -90.0, 0.0); + arc_cubic(&mut d, Point::new(150.0, 50.0), 30.0, 0.0, 90.0); + d.close_path(); + [PathData::from_bezpath(&slot), PathData::from_bezpath(&d), shapes::rounded_rectangle(Rect::new(20.0, 20.0, 100.0, 80.0), 10.0)] +} + +/// A polyline entity's vertices as (x, y, bulge): an LWPOLYLINE's, or those of the VERTEX +/// entities that follow a POLYLINE at `e[i]`. +fn vertices(e: &[Pairs], i: usize) -> Vec<(f64, f64, f64)> { + let mut out = vec![]; + let mut read = |o: &Pairs| { + for (code, value) in o { + let v: f64 = value.trim().parse().unwrap_or(0.0); + match code { + 10 => out.push((v, 0.0, 0.0)), + 20 => out.last_mut().expect("x before y").1 = v, + 42 => out.last_mut().expect("a vertex before its bulge").2 = v, + _ => {} + } + } + }; + if kind(&e[i]) == "LWPOLYLINE" { + read(&e[i]); + } else { + e[i + 1..].iter().take_while(|o| kind(o) == "VERTEX").for_each(&mut read); + } + out +} + +#[test] +fn paths_of_lines_and_circular_arcs_are_polylines_with_bulges() { + let [slot, d_shape, rounded] = lines_and_arcs(); + let d = doc_with(|d| { + let black = || Paint::solid(Color::BLACK); + let mut open = vectorcraft_geom::BezPath::new(); + open.move_to((10.0, 90.0)); + open.line_to((30.0, 90.0)); + arc_cubic(&mut open, Point::new(30.0, 80.0), 10.0, 90.0, 0.0); + vec![ + path(d, slot, Paint::None, black(), 1.0), + path(d, d_shape, Paint::None, black(), 1.0), + path(d, rounded, Paint::None, black(), 1.0), + path(d, PathData::from_bezpath(&open), Paint::None, black(), 1.0), + ] + }); + let b = (std::f64::consts::PI / 8.0).tan(); + for version in DxfVersion::ALL { + let out = export(&d, &DxfOptions { version, ..opts(&d) }).unwrap(); + let e = entities(&out); + let polys: Vec = (0..e.len()).filter(|i| matches!(kind(&e[*i]), "LWPOLYLINE" | "POLYLINE")).collect(); + let k = kinds(&out); + assert_eq!(polys.len(), 4, "{version:?}: {k:?}"); + assert!(!k.iter().any(|k| matches!(k.as_str(), "SPLINE" | "ARC" | "CIRCLE")), "{version:?}: {k:?}"); + let want = if version.lwpolyline() { "LWPOLYLINE" } else { "POLYLINE" }; + assert!(polys.iter().all(|i| kind(&e[*i]) == want), "{version:?}: {k:?}"); + let flags: Vec = polys.iter().map(|i| get(&e[*i], 70).unwrap().parse::().unwrap() & 1).collect(); + assert_eq!(flags, [1, 1, 1, 0], "{version:?}: closed, closed, closed, open"); + // The slot's half circles turn clockwise with y up: a bulge of -1 after each long side. + assert_eq!(vertices(&e, polys[0]), [(60.0, 70.0, 0.0), (140.0, 70.0, -1.0), (140.0, 30.0, 0.0), (60.0, 30.0, -1.0)], "{version:?}"); + // The D shape: the half circle, then the straight closing edge. + assert_eq!(vertices(&e, polys[1]), [(150.0, 80.0, -1.0), (150.0, 20.0, 0.0)], "{version:?}"); + // The rounded rectangle: eight anchors, a quarter circle (bulge tan 22.5°) at each corner. + let r = vertices(&e, polys[2]); + assert_eq!(r.len(), 8, "{version:?}: {r:?}"); + let bulges: Vec = r.iter().map(|v| v.2).filter(|b| *b != 0.0).collect(); + assert!(close_to(&bulges, &[-b; 4], 1e-6), "{version:?}: {r:?}"); + // An open line then a quarter circle turning counterclockwise with y up. + let o = vertices(&e, polys[3]); + assert!( + close_to(&o.iter().flat_map(|v| [v.0, v.1, v.2]).collect::>(), &[10.0, 10.0, 0.0, 30.0, 10.0, b, 40.0, 20.0, 0.0], 1e-6), + "{version:?}: {o:?}" + ); + } +} + +#[test] +fn polylines_with_bulges_read_back_as_the_same_outline() { + for (i, shape) in lines_and_arcs().into_iter().enumerate() { + let original = shape.to_bezpath(); + let d = doc_with(|d| vec![path(d, shape, Paint::None, Paint::solid(Color::BLACK), 1.0)]); + let out = export(&d, &opts(&d)).unwrap(); + assert_eq!(kinds(&out), ["LWPOLYLINE"], "shape {i}"); + let o = ImportOptions { unit: Some(Unit::Points), scale: Some(1.0), center: false, ..ImportOptions::default() }; + let back = import(&out.bytes, &o).unwrap().document; + let mut paths = vec![]; + back.walk(|n| { + if let NodeKind::Path { path, .. } = &n.kind { + paths.push(path.to_bezpath()); + } + }); + assert_eq!(paths.len(), 1, "shape {i}"); + assert!(paths[0].elements().last().is_some_and(|el| matches!(el, kurbo::PathEl::ClosePath)), "shape {i} reads back closed"); + let (a, b) = (original.bounding_box(), paths[0].bounding_box()); + assert!(close_to(&[a.width(), a.height()], &[b.width(), b.height()], 1e-2), "shape {i}: {a:?} against {b:?}"); + // Every point read back lies on the original outline, once both are centered. + let shift = a.center() - b.center(); + let outline: Vec = original.segments().flat_map(|s| (0..=64).map(move |t| s.eval(f64::from(t) / 64.0))).collect(); + for seg in paths[0].segments() { + for t in 0..=16 { + let p = seg.eval(f64::from(t) / 16.0) + shift; + let near = outline + .windows(2) + .map(|w| { + let (u, v) = (w[1] - w[0], p - w[0]); + let s = if u.hypot2() > 0.0 { (v.dot(u) / u.hypot2()).clamp(0.0, 1.0) } else { 0.0 }; + (v - u * s).hypot() + }) + .fold(f64::INFINITY, f64::min); + assert!(near < 1e-2, "shape {i}: {p:?} is {near} pt off the outline"); + } + } + } +} + +#[test] +fn a_path_with_a_curve_that_is_not_a_circular_arc_stays_a_spline() { + // The slot with one end drawn by a longer-handled curve. + let mut slot = vectorcraft_geom::BezPath::new(); + slot.move_to((60.0, 30.0)); + slot.line_to((140.0, 30.0)); + slot.curve_to((165.0, 30.0), (165.0, 70.0), (140.0, 70.0)); + slot.line_to((60.0, 70.0)); + arc_cubic(&mut slot, Point::new(60.0, 50.0), 20.0, 90.0, 180.0); + arc_cubic(&mut slot, Point::new(60.0, 50.0), 20.0, 180.0, 270.0); + slot.close_path(); + let d = doc_with(|d| { + vec![ + path(d, PathData::from_bezpath(&slot), Paint::None, Paint::solid(Color::BLACK), 1.0), + path(d, shapes::ellipse(Rect::new(150.0, 20.0, 190.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0), + ] + }); + assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["SPLINE", "CIRCLE"]); +} + +#[test] +fn drawing_extents_reach_the_extreme_points_of_arcs() { + // An arc about (100, 50) of radius 40 from -60° to 30° passes x = 140 at 0°, between the points + // along it that recognition samples. + let arc = |closed: bool| { + let mut b = vectorcraft_geom::BezPath::new(); + let start = Point::new(100.0 + 40.0 * 60f64.to_radians().cos(), 50.0 - 40.0 * 60f64.to_radians().sin()); + b.move_to(start); + arc_cubic(&mut b, Point::new(100.0, 50.0), 40.0, -60.0, 30.0); + if closed { + b.close_path(); + } + PathData::from_bezpath(&b) + }; + for (closed, entity) in [(false, "ARC"), (true, "LWPOLYLINE")] { + let d = doc_with(|d| vec![path(d, arc(closed), Paint::None, Paint::solid(Color::BLACK), 1.0)]); + let out = export(&d, &opts(&d)).unwrap(); + assert_eq!(kinds(&out), [entity]); + let x: f64 = header(&pairs(&out.bytes), "$EXTMAX").unwrap().trim().parse().unwrap(); + assert!((x - 140.0).abs() < 1e-6, "{entity}: $EXTMAX x {x}"); + } +} + +#[test] +fn a_shallow_curve_that_is_not_circular_is_not_an_arc() { + // 100 pt across and 1.5 pt deep: the circle through its ends and middle has a radius of about + // 834 pt, and the curve is 0.12 pt off it a quarter of the way along. + let mut b = vectorcraft_geom::BezPath::new(); + b.move_to((0.0, 50.0)); + b.curve_to((20.0, 48.0), (80.0, 48.0), (100.0, 50.0)); + let d = doc_with(|d| vec![path(d, PathData::from_bezpath(&b), Paint::None, Paint::solid(Color::BLACK), 1.0)]); + assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["SPLINE"]); +} + /// The path `p` with `xf` applied. fn transformed(p: &PathData, xf: Affine) -> PathData { PathData::from_bezpath(&(xf * p.to_bezpath())) @@ -391,11 +582,12 @@ fn closed_four_cubic_shapes_that_are_not_writable_ellipses_stay_splines() { vec![ path(d, PathData::from_bezpath(&squircle), Paint::None, Paint::solid(Color::BLACK), 1.0), path(d, PathData::from_bezpath(&kurbo::BezPath::from_vec(els)), Paint::None, Paint::solid(Color::BLACK), 1.0), - // An ellipse thinner than the smallest ratio DXF readers take (5e-7 against 1e-6). + // An ellipse thinner than the smallest ratio DXF readers take (5e-7 against 1e-6). Its + // quarters fit shallow circular arcs within the tolerance, so it is a polyline with bulges. path(d, shapes::ellipse(Rect::new(0.0, 90.0, 200.0, 90.0001)), Paint::None, Paint::solid(Color::BLACK), 1.0), ] }); - assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["SPLINE", "SPLINE", "SPLINE"]); + assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["SPLINE", "SPLINE", "LWPOLYLINE"]); } #[test] @@ -462,8 +654,12 @@ fn closed_circular_arc_with_a_closing_chord_is_not_a_circle() { vec![path(d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0)] }); + // The three quarters turn counterclockwise with y up (bulge tan 67.5°), then the chord closes it. let modern = export(&d, &opts(&d)).unwrap(); - assert_eq!(kinds(&modern), ["SPLINE"]); + assert_eq!(kinds(&modern), ["LWPOLYLINE"]); + let v = vertices(&entities(&modern), 0); + let flat: Vec = v.iter().flat_map(|v| [v.0, v.1, v.2]).collect(); + assert!(close_to(&flat, &[80.0, 50.0, 67.5f64.to_radians().tan(), 50.0, 20.0, 0.0], 1e-6), "{v:?}"); let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap(); assert!(kinds(&r12).iter().any(|k| k == "POLYLINE")); assert!(!kinds(&r12).iter().any(|k| k == "CIRCLE")); @@ -497,9 +693,13 @@ fn a_closed_sliver_along_a_circle_is_not_a_circle() { vec![path(d, PathData::from_bezpath(&sliver), Paint::None, Paint::solid(Color::BLACK), 1.0)] }); + // Two arcs, out along the circle and back: one polyline with a bulge each way. let modern = export(&d, &opts(&d)).unwrap(); assert_ne!(kinds(&modern), ["CIRCLE"]); - assert_eq!(kinds(&modern), ["SPLINE"]); + assert_eq!(kinds(&modern), ["LWPOLYLINE"]); + let bulges: Vec = vertices(&entities(&modern), 0).iter().map(|v| v.2).collect(); + let b = 10f64.to_radians().tan(); + assert!(close_to(&bulges, &[-b, b], 1e-6) || close_to(&bulges, &[b, -b], 1e-6), "{bulges:?}"); } #[test] @@ -1007,3 +1207,22 @@ fn crop_keeps_an_instance_whose_art_reaches_the_region() { let o = DxfOptions { crop: true, ..opts(&d) }; assert!(!kinds(&export(&d, &o).unwrap()).is_empty()); } + +#[test] +fn symbols_fanning_out_into_empty_symbols_stop_at_the_symbol_budget() { + // Five levels of 100 instances of the level below, ending in an empty symbol: 10^10 empty + // instances, which write no entities, past the million objects tests export with. + let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "L5".into(), xf: Affine::IDENTITY })]); + let empty = Node::group(d.alloc_id(), vec![]); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "L0".into(), art: Arc::new(empty) }); + for level in 1..=5 { + let below = format!("L{}", level - 1); + let children = + (0..100).map(|_| Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: below.clone(), xf: Affine::IDENTITY }))).collect(); + let art = Node::group(d.alloc_id(), children); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: format!("L{level}"), art: Arc::new(art) }); + } + let out = export(&d, &opts(&d)).unwrap(); + assert!(entities(&out).is_empty()); + assert!(out.warnings.iter().any(|w| w.contains("symbols placed inside symbols")), "{:?}", out.warnings); +} diff --git a/crates/color/src/cms/icc.rs b/crates/color/src/cms/icc.rs index 64f42f25b..0b09062b8 100644 --- a/crates/color/src/cms/icc.rs +++ b/crates/color/src/cms/icc.rs @@ -106,6 +106,12 @@ impl IccProfile { if !localized_text_fits(bytes) { return Err(CmsError::BadProfile(format!("its localized names take more than {} KB", MAX_LOCALIZED_TEXT >> 10))); } + if let Some(tag) = repeated_tag(bytes) { + return Err(CmsError::BadProfile(format!( + "its tag table lists the `{}` tag more than once", + String::from_utf8_lossy(&tag.to_be_bytes()) + ))); + } let p = ColorProfile::new_from_slice(bytes).map_err(|e| CmsError::BadProfile(format!("{e:?}")))?; Ok(Self { source: bytes.to_vec(), ..Self::from_profile(name, p)? }) } @@ -417,6 +423,23 @@ pub(super) fn gray_profile(name: &str) -> ColorProfile { labelled(p, name) } +/// The first tag signature the tag table of ICC `bytes` lists twice, which the ICC +/// specification forbids. moxcms parses every entry of the table, so a profile listing one +/// description, copyright or table tag thousands of times, each pointing at the same large data, +/// would decode and copy that data once per entry. A table it can't read is left to moxcms. +pub(crate) fn repeated_tag(bytes: &[u8]) -> Option { + let be32 = |at: usize| bytes.get(at..at.checked_add(4)?).map(|v| u32::from_be_bytes([v[0], v[1], v[2], v[3]])); + let tags = be32(128)? as usize; + let mut seen = std::collections::HashSet::new(); + for i in 0..tags { + let sig = be32(i.checked_mul(12)?.checked_add(132)?)?; + if !seen.insert(sig) { + return Some(sig); + } + } + None +} + /// Most bytes of UTF-16 the localized names (`mluc` tags) of a profile may take together. const MAX_LOCALIZED_TEXT: usize = 1 << 20; diff --git a/crates/color/src/cms/tests_icc.rs b/crates/color/src/cms/tests_icc.rs index 46e44cd76..1d312c09c 100644 --- a/crates/color/src/cms/tests_icc.rs +++ b/crates/color/src/cms/tests_icc.rs @@ -181,6 +181,32 @@ fn aliased_localized_names_are_refused() { assert!(!super::icc::localized_text_fits(&profile(4096, 64 << 10))); } +/// A tag table that lists one tag many times, every entry pointing at the same long text, is +/// refused before moxcms decodes the text once per entry; distinct tags pass. +#[test] +fn repeated_tags_are_refused() { + let profile = |sigs: &[&[u8; 4]], text: usize| { + let mut b = vec![0u8; 128]; + b.extend((sigs.len() as u32).to_be_bytes()); + let tag_at = 128 + 4 + 12 * sigs.len(); + let size = 8 + text; + for sig in sigs { + b.extend(*sig); + b.extend((tag_at as u32).to_be_bytes()); + b.extend((size as u32).to_be_bytes()); + } + b.extend(b"text"); + b.extend([0; 4]); + b.extend(vec![b'a'; text]); + b + }; + assert_eq!(super::icc::repeated_tag(&profile(&[b"desc", b"cprt", b"dmdd"], 64)), None); + let many: Vec<&[u8; 4]> = std::iter::repeat_n(b"desc", 4096).collect(); + let bytes = profile(&many, 256 << 10); + assert_eq!(super::icc::repeated_tag(&bytes), Some(u32::from_be_bytes(*b"desc"))); + assert!(matches!(super::icc::IccProfile::from_bytes(None, &bytes), Err(super::CmsError::BadProfile(_)))); +} + /// Reloading an RGB profile under the name it had (a new file for "My RGB") changes what CMYK /// converts to in it: the direct CMYK → RGB transform isn't kept from the old file. #[test] diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 402cf3ae2..794f92aee 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -86,6 +86,9 @@ const TINT_STEPS: u32 = 16; const MIN_INK_LIGHTNESS_SPAN: f64 = 1.0 / 255.0; /// Most stops on a gradient's curve that [`Gradient::ink_tints`] finds tints for. const MAX_INK_TINTS: usize = 4096; +/// How many even steps [`Gradient::ink_mixes`] checks the lightness of two stops' display RGB mix +/// at, to tell whether it moves steadily from one stop's lightness to the other's. +const INK_MIX_STEPS: u32 = 64; /// The mix positions (0..1, after `v0`, before `v1`) between which display RGB mixed linearly /// stays within [`PERCEPTUAL_TOLERANCE`] of the perceptual mix of `a` and `b`: the span halved @@ -175,6 +178,40 @@ fn between(a: &GradientStop, b: &GradientStop, t: f32, mix: &impl Fn(&Color, &Co (mix(&a.color, &b.color, v), a.opacity + (b.opacity - a.opacity) * v) } +/// For each pair of neighbouring stops (`k`, `k + 1`), the largest offset among stops `1..=k + 1`. +/// [`Gradient::sample_with`] picks the first pair whose end offset `t` doesn't pass; that is the +/// first pair whose reach `t` doesn't pass, and the reaches never decrease, so the pair is found by +/// binary search whether or not the stops are in order. +struct StopReach(Vec); + +impl StopReach { + fn new(stops: &[GradientStop]) -> Self { + // `f32::max` skips a NaN offset, which `t <= offset` never chooses either. + let reach = stops.iter().skip(1).scan(f32::NEG_INFINITY, |m, s| { + *m = m.max(s.offset); + Some(*m) + }); + Self(reach.collect()) + } + + /// What [`Gradient::sample_with`] gives at `t` for `stops`, the stops this was built from. + fn sample_with(&self, stops: &[GradientStop], t: f32, mix: &impl Fn(&Color, &Color, f32) -> Color) -> (Color, f32) { + let Some(first) = stops.first() else { return (Color::BLACK, 1.0) }; + if t <= first.offset { + return (first.color, first.opacity); + } + let last = || stops.last().map_or((Color::BLACK, 1.0), |l| (l.color, l.opacity)); + if t.is_nan() { + return last(); + } + let k = self.0.partition_point(|r| *r < t); + match (stops.get(k), stops.get(k + 1)) { + (Some(a), Some(b)) => between(a, b, t, mix), + _ => last(), + } + } +} + fn one() -> f32 { 1.0 } @@ -211,6 +248,12 @@ impl Gradient { pub fn sample(&self, t: f32) -> (Color, f32) { self.sample_with(t, |a, b, v| self.method.mix(a, b, v)) } + /// [`Self::sample`] for many points in no order (a gradient drawn pixel by pixel): the stops + /// are prepared once, so each sample costs a binary search rather than a scan of the stops. + pub fn sampler(&self) -> impl Fn(f32) -> (Color, f32) + '_ { + let reach = StopReach::new(&self.stops); + move |t| reach.sample_with(&self.stops, t, &|a, b, v| self.method.mix(a, b, v)) + } /// [`Self::sample`], a Classic gradient's neighbouring stop colours of one model mixed in it /// (CMYK stays CMYK; [`Color::lerp_keep_model`]) rather than in display RGB, a Perceptual /// gradient's as [`Self::sample`] mixes them. @@ -245,9 +288,11 @@ impl Gradient { /// the one before it stopped, so the work is the samples plus the stops, not their product. pub fn sample_increasing_with(&self, ts: impl IntoIterator, mix: impl Fn(&Color, &Color, f32) -> Color) -> Vec<(Color, f32)> { let stops = &self.stops; - // The shared index assumes ordered stops; others (a file's, unsorted) sample one by one. + // The shared cursor assumes ordered stops; others (a file's, unsorted) look each sample up + // in a [`StopReach`], so they cost a binary search each rather than a scan of the stops. if stops.windows(2).any(|w| w[1].offset < w[0].offset) { - return ts.into_iter().map(|t| self.sample_with(t, &mix)).collect(); + let reach = StopReach::new(stops); + return ts.into_iter().map(|t| reach.sample_with(stops, t, &mix)).collect(); } let mut j = 0; ts.into_iter() @@ -284,6 +329,9 @@ impl Gradient { fn expanded_with_stops(&self) -> impl Iterator, Option)> + '_ { // Direct interpolation matches `sample` only for strictly increasing offsets. let increasing = self.stops.windows(2).all(|w| w[0].offset < w[1].offset); + // Otherwise (repeated or unsorted offsets) each midpoint is looked up as `sample` would + // find it, but by binary search: a scan of all the stops per midpoint cost their square. + let reach = if increasing || !self.method.is_classic() { None } else { Some(StopReach::new(&self.stops)) }; self.stops.iter().enumerate().flat_map(move |(i, s)| { let next = self.stops.get(i + 1); let off_centre = (s.midpoint - 0.5).abs() > 1e-3; @@ -293,7 +341,10 @@ impl Gradient { // Mixed from the two stops it lies between, as `sample` would: searching all the // stops again for each midpoint made many stops cost their square. let mix = |a: &Color, b: &Color, v| self.method.mix(a, b, v); - let (c, o) = if increasing { between(s, n, t, &mix) } else { self.sample_with(t, mix) }; + let (c, o) = match &reach { + Some(reach) => reach.sample_with(&self.stops, t, &mix), + None => between(s, n, t, &mix), + }; (t, c, o, Some(s.midpoint), None) }); // Perceptual: stops along the mix (`v`) where a straight line would stray from it, the @@ -353,8 +404,10 @@ impl Gradient { /// The weight is the one at which the two stops' colors mixed in display RGB have the OKLab /// lightness of the color on the curve there, found to [`TINT_STEPS`] halvings, or the mix /// position itself when the two colors' lightness differs by less than - /// [`MIN_INK_LIGHTNESS_SPAN`]. `None` for a Classic gradient, and when the curve has more than - /// [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]). + /// [`MIN_INK_LIGHTNESS_SPAN`], or when the display RGB mix's lightness turns back somewhere + /// on the way from one color's lightness to the other's (checked at [`INK_MIX_STEPS`] even + /// steps), as from red to blue. `None` for a Classic gradient, and when the curve has more + /// than [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]). pub fn ink_mixes(&self) -> Option> { use crate::libraries::srgb_to_oklab; if self.method.is_classic() || !self.fits_ink_tints() { @@ -366,8 +419,16 @@ impl Gradient { out.push((s.offset, i, 0.0, s.opacity)); let Some(n) = self.stops.get(i + 1) else { continue }; let (l0, l1) = (lightness(&s.color), lightness(&n.color)); + let mixed_lightness = |w: f32| lightness(&GradientMethod::Classic.mix(&s.color, &n.color, w)); + // The mix position itself when the lightness doesn't tell the mixes apart. + let by_position = (l1 - l0).abs() < MIN_INK_LIGHTNESS_SPAN + || !(0..=INK_MIX_STEPS) + .map(|k| mixed_lightness(k as f32 / INK_MIX_STEPS as f32)) + .collect::>() + .windows(2) + .all(|w| (w[1] - w[0]) * (l1 - l0) >= 0.0); for v in perceptual_mixes(s, n) { - let weight = if (l1 - l0).abs() < MIN_INK_LIGHTNESS_SPAN { + let weight = if by_position { v } else { let want = lightness(&self.method.mix(&s.color, &n.color, v)); @@ -375,7 +436,7 @@ impl Gradient { for _ in 0..TINT_STEPS { let mid = (lo + hi) / 2.0; // The mix at `mid` has not yet gone from stop `i`'s lightness to the wanted one. - if (lightness(&GradientMethod::Classic.mix(&s.color, &n.color, mid)) - want) * (l1 - l0) < 0.0 { + if (mixed_lightness(mid) - want) * (l1 - l0) < 0.0 { lo = mid; } else { hi = mid; diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs index 224803075..1005b8430 100644 --- a/crates/color/src/gradient/tests.rs +++ b/crates/color/src/gradient/tests.rs @@ -23,6 +23,64 @@ fn increasing_samples_match_single_samples() { assert_eq!(g.sample_increasing_with(ts, Color::lerp_keep_model), one_by_one); } +/// The prepared sampler gives what sampling each position alone does, out-of-order stops included. +#[test] +fn the_sampler_matches_single_samples() { + let stop = |offset: f32, hex: &str| GradientStop::new(offset, Color::from_hex(hex).unwrap()); + let g = Gradient::new( + GradientKind::Linear, + vec![stop(0.1, "#ff0000"), stop(0.4, "#00ff00"), stop(0.4, "#0000ff"), stop(0.2, "#ffff00"), stop(0.9, "#000000")], + ); + let sample = g.sampler(); + for k in (0..=40).rev() { + let t = k as f32 / 40.0; + assert_eq!(sample(t), g.sample(t), "{t}"); + } +} + +/// Looking stops up by their reach finds what scanning them does, for any order of offsets, +/// repeated, out-of-order and NaN ones included, and for positions outside and between them. +#[test] +fn stop_reach_matches_scanning_the_stops() { + let colors = ["#ff0000", "#00ff00", "#0000ff", "#ffff00", "#00ffff", "#000000"]; + let offsets: [&[f32]; 5] = [ + &[0.1, 0.4, 0.4, 0.2, 0.9, 0.5], + &[0.5, 0.5, 0.5, 0.5], + &[0.9, 0.1, 0.8, 0.2, 0.7, 0.3], + &[0.2, f32::NAN, 0.6, 0.1, f32::NAN, 1.0], + &[f32::NAN, 0.3, 0.3, 0.8], + ]; + for offsets in offsets { + let stops: Vec = offsets + .iter() + .zip(colors.iter().cycle()) + .map(|(&o, hex)| GradientStop { midpoint: 0.3, ..GradientStop::new(o, Color::from_hex(hex).unwrap()) }) + .collect(); + let g = Gradient::new(GradientKind::Linear, stops); + let reach = StopReach::new(&g.stops); + for t in (-4..=44).map(|k| k as f32 / 40.0).chain([f32::NAN, f32::INFINITY, f32::NEG_INFINITY]) { + // Compared as printed: a NaN offset mixes NaN colours, which are equal only so. + let (got, want) = (reach.sample_with(&g.stops, t, &Color::lerp_keep_model), g.sample_with(t, Color::lerp_keep_model)); + assert_eq!(format!("{got:?}"), format!("{want:?}"), "{offsets:?} at {t}"); + } + } +} + +/// Repeated offsets turn off the direct midpoint mix, and the midpoints still come out as +/// `sample` gives them, with work that stays near linear in the stops (thousands of stops at one +/// offset used to scan them all for each midpoint). +#[test] +fn expanded_with_repeated_offsets_samples_midpoints() { + let stop = |offset: f32, hex: &str| GradientStop { midpoint: 0.25, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) }; + let g = Gradient::new(GradientKind::Linear, vec![stop(0.0, "#ff0000"), stop(0.5, "#00ff00"), stop(0.5, "#0000ff"), stop(1.0, "#000000")]); + for (t, c, o, mid) in g.expanded().filter(|e| e.3.is_some()) { + assert_eq!(mid, Some(0.25)); + assert_eq!((c, o), g.sample(t), "midpoint at {t}"); + } + let many = Gradient::new(GradientKind::Linear, (0..200_000).map(|i| stop(if i == 0 { 0.0 } else { 0.5 }, "#808080")).collect()); + assert_eq!(many.expanded().count(), 2 * 200_000 - 1); +} + #[test] fn midpoint_shifts() { let mut g = Gradient::default(); @@ -527,3 +585,22 @@ fn ink_mixes_follow_the_perceptual_curve() { g.stops = (0..n).map(|i| GradientStop::new(i as f32 / (n - 1) as f32, if i % 2 == 0 { magenta } else { Color::WHITE })).collect(); assert_eq!(g.ink_mixes(), None); } + +/// From red to blue the display RGB mix darkens past blue's lightness and comes back, so a +/// weight found by lightness near the blue stop would be a red-side one and the inks would jump +/// there. The weights are the mix positions instead, and reach the blue stop's inks steadily. +#[test] +fn ink_mixes_use_the_mix_position_when_the_lightness_turns_back() { + let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)); + let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, red), GradientStop::new(1.0, blue)]); + g.method = GradientMethod::Perceptual; + let mixes = g.ink_mixes().expect("a Perceptual gradient's curve"); + assert_eq!(mixes[mixes.len() - 1], (1.0, 1, 0.0, 1.0), "the blue stop"); + let between: Vec<_> = mixes.iter().filter(|m| m.1 == 0 && m.0 > 0.0).collect(); + assert!(!between.is_empty(), "stops on the curve between red and blue: {mixes:?}"); + // With the midpoint at ½, a stop's offset on the curve is its mix position. + for (t, _, w, _) in &between { + assert!((w - t).abs() < 1e-6, "at {t}: weight {w}"); + } + assert!(mixes.windows(2).filter(|p| p[1].1 == 0).all(|p| p[0].2 <= p[1].2), "the weights rise: {mixes:?}"); +} diff --git a/crates/color/src/recolor.rs b/crates/color/src/recolor.rs index e7cf22c41..5117f16a3 100644 --- a/crates/color/src/recolor.rs +++ b/crates/color/src/recolor.rs @@ -433,10 +433,11 @@ pub fn cluster(colors: &[Color], weights: &[f32], n: usize) -> Vec> { .collect(); let w = |i: usize| weights.get(i).copied().unwrap_or(1.0).max(1e-6); let d2 = |a: &[f32; 3], b: &[f32; 3]| (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2); - let n = n.max(1); if pts.is_empty() { return vec![]; } + // No more rows than colours: each colour seeds one at most. + let n = n.clamp(1, pts.len()); // Seeds: the heaviest colour, then each time the one farthest from every seed so far. let first = (0..pts.len()).fold(0, |best, i| if w(i) > w(best) { i } else { best }); let mut centres = vec![pts[first]]; @@ -447,6 +448,8 @@ pub fn cluster(colors: &[Color], weights: &[f32], n: usize) -> Vec> { break; } centres.push(pts[i]); + // Picked: a colour whose distances aren't finite (an infinite coordinate) stays farthest. + near[i] = 0.0; for (j, p) in pts.iter().enumerate() { near[j] = near[j].min(d2(p, &pts[i])); } @@ -670,6 +673,12 @@ mod tests { assert!(matches!(shaded(lab[0], 0.5), Color::Lab { .. })); } + #[test] + fn clustering_seeds_each_colour_once_even_unbounded() { + let colors = [Color::lab(f32::INFINITY, 0.0, 0.0), Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)]; + assert!(cluster(&colors, &[], usize::MAX).len() <= colors.len()); + } + #[test] fn clustering_reduces_to_n_rows_of_similar_colours() { let colors = diff --git a/crates/doc/src/appearance/profile.rs b/crates/doc/src/appearance/profile.rs index 1c3334f0d..01b9c9fc5 100644 --- a/crates/doc/src/appearance/profile.rs +++ b/crates/doc/src/appearance/profile.rs @@ -34,11 +34,11 @@ impl WidthProfile { if t <= p[0].0 { return (p[0].1, p[0].2); } - for w in p.windows(2) { - if t <= w[1].0 { - let u = (t - w[0].0) / (w[1].0 - w[0].0).max(1e-9); - return (w[0].1 + (w[1].1 - w[0].1) * u, w[0].2 + (w[1].2 - w[0].2) * u); - } + // (Positions go forward; found in log time, so a long profile read from a file is cheap to ask.) + let i = p.partition_point(|q| q.0 < t); + if let (Some(a), Some(b)) = (i.checked_sub(1).and_then(|j| p.get(j)), p.get(i)) { + let u = (t - a.0) / (b.0 - a.0).max(1e-9); + return (a.1 + (b.1 - a.1) * u, a.2 + (b.2 - a.2) * u); } p.last().map_or((1.0, 1.0), |l| (l.1, l.2)) } @@ -90,10 +90,12 @@ impl WidthProfile { /// discontinuous point (two points at `t`, within `1e-7`). pub fn around(&self, t: f64) -> ((f64, f64), (f64, f64)) { let p = &self.points; - if let Some(i) = p.iter().position(|q| (q.0 - t).abs() <= 1e-7) { - let j = i + p[i..].iter().take_while(|q| (q.0 - p[i].0).abs() <= 1e-9).count() - 1; + // The same distance test below, so a point exactly 1e-7 before `t` is never skipped by rounding. + let i = p.partition_point(|q| q.0 < t && (q.0 - t).abs() > 1e-7); + if let Some(first) = p.get(i).filter(|q| (q.0 - t).abs() <= 1e-7) { + let j = i + p[i..].partition_point(|q| q.0 - first.0 <= 1e-9) - 1; if j > i { - return ((p[i].1, p[i].2), (p[j].1, p[j].2)); + return ((first.1, first.2), (p[j].1, p[j].2)); } } let v = self.at(t); @@ -334,3 +336,26 @@ pub struct SavedProfile { pub name: String, pub profile: WidthProfile, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn width_profile_lookups_by_search() { + let p = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (0.5, 0.5, 0.25), (1.0, 0.0, 0.0)] }; + assert_eq!(p.at(-1.0), (0.0, 0.0)); + assert_eq!(p.at(0.25), (0.5, 0.5)); + assert_eq!(p.at(0.75), (0.25, 0.125)); + assert_eq!(p.at(2.0), (0.0, 0.0)); + // A step: the widths just before and just after it. + assert_eq!(p.around(0.5), ((1.0, 1.0), (0.5, 0.25))); + assert_eq!(p.around(0.25), ((0.5, 0.5), (0.5, 0.5))); + // Many points at one position are one step, found without a pass over them all. + let many = WidthProfile { points: (0..1000).map(|i| (0.5, i as f64, i as f64)).collect() }; + assert_eq!(many.around(0.5), ((0.0, 0.0), (999.0, 999.0))); + // 0.4999999 == 0.5 - 1e-7, yet its distance from 0.5 rounds above 1e-7: it is not part of the step. + let near = WidthProfile { points: vec![(0.4999999, 2.0, 2.0), (0.5, 1.0, 1.0), (0.5, 0.5, 0.25)] }; + assert_eq!(near.around(0.5), ((1.0, 1.0), (0.5, 0.25))); + } +} diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index ce20d29cd..8bca517ee 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -862,6 +862,52 @@ impl Spine { pub fn anchor_length(&self, i: usize) -> Option { self.anchors.get(i).copied() } + /// Arc length of the point of the spine nearest `p`. An open spine's end segments run on past + /// its ends as lines, so the result is below 0 before the start and above [`Spine::length`] + /// beyond the end. With `from`, the arc length of an earlier point a drag followed, each point + /// counts as farther by half its distance along the spine from there, so a pointer passing + /// near another part of the spine stays on the part it follows. Where two points count as + /// near as each other, the one earlier along the spine counts. + pub fn length_near(&self, p: Point, from: Option) -> f64 { + self.nearest(p, from).map_or(0.0, |(_, s)| s) + } + /// [`Spine::length_near`] with how near the point counts: its distance from `p`, plus half its + /// distance along the spine from `from`. `None` for a point that is not finite. + pub fn nearest(&self, p: Point, from: Option) -> Option<(f64, f64)> { + if !p.x.is_finite() || !p.y.is_finite() { + return None; + } + let last = self.pts.len().saturating_sub(2); + let mut best: Option<(f64, f64)> = None; + for (i, (w, c)) in self.pts.windows(2).zip(&self.cum).enumerate() { + let [a, b] = [w[0], w[1]]; + let d = b - a; + let l2 = d.dot(d); + if !l2.is_finite() || l2 <= 0.0 { + continue; + } + let t = (p - a).dot(d) / l2; + let t = match (self.closed, i == 0, i == last) { + (false, true, true) => t, + (false, true, false) => t.min(1.0), + (false, false, true) => t.max(0.0), + _ => t.clamp(0.0, 1.0), + }; + let s = c + t * l2.sqrt(); + let dist = p.distance(a + d * t) + from.filter(|f| f.is_finite()).map_or(0.0, |f| self.apart(s, f) / 2.0); + if dist.is_finite() && best.is_none_or(|(b, _)| dist < b) { + best = Some((dist, s)); + } + } + best + } + /// How far apart arc lengths `s` and `f` are along the spine: the shorter way round a closed + /// spine, across its first anchor. + pub fn apart(&self, s: f64, f: f64) -> f64 { + let len = self.length(); + let d = (s - f).abs(); + if self.closed && len > 0.0 { d.rem_euclid(len).min(len - d.rem_euclid(len)) } else { d } + } /// Point and tangent angle (radians) at arc-length fraction `f` (0..1). pub fn at(&self, f: f64) -> (Point, f64) { self.at_length(f.clamp(0.0, 1.0) * self.length()) diff --git a/crates/doc/src/depth.rs b/crates/doc/src/depth.rs index 6f10da9b7..5abba9c63 100644 --- a/crates/doc/src/depth.rs +++ b/crates/doc/src/depth.rs @@ -107,15 +107,21 @@ fn nested_deeper(now: &[Arc], depth: usize) -> bool { /// an opacity mask too deep are left out); what it held stays hidden or locked as it was. Returns /// whether anything changed. Recurses no deeper than the cap. fn limit(nodes: &mut Vec>, depth: usize) -> bool { - let mut changed = false; - let mut i = 0; - while let Some(n) = nodes.get_mut(i) { - if !nested_deeper(std::slice::from_ref(n), depth) { - i += 1; + if !nested_deeper(nodes, depth) { + return false; + } + // The list is rebuilt in order rather than spliced in place: replacing one container of many + // by what it holds would move all the siblings after it each time. + let mut out = Vec::with_capacity(nodes.len()); + // What is left to look at, the next one last. + let mut left: Vec> = std::mem::take(nodes); + left.reverse(); + while let Some(mut a) = left.pop() { + if !nested_deeper(std::slice::from_ref(&a), depth) { + out.push(a); continue; } - changed = true; - let n = Arc::make_mut(n); + let n = Arc::make_mut(&mut a); if n.extras().mask.as_ref().is_some_and(|m| nested_deeper(std::slice::from_ref(&m.art), depth + 1)) { n.extras_mut().mask = None; } @@ -123,7 +129,7 @@ fn limit(nodes: &mut Vec>, depth: usize) -> bool { if let Some(ch) = n.children_mut() { limit(ch, depth + 1); } - i += 1; + out.push(a); continue; } let clip = matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }); @@ -140,9 +146,10 @@ fn limit(nodes: &mut Vec>, depth: usize) -> bool { } } // What it held takes its place, and is looked at in its turn. - nodes.splice(i..=i, held); + left.extend(held.into_iter().rev()); } - changed + *nodes = out; + true } /// A symbol (`false`) or a pattern (`true`) of the document, by name: what art draws by name, an @@ -439,6 +446,24 @@ mod tests { } } + #[test] + fn many_containers_at_the_cap_are_merged_in_order() { + // A group just above the cap holding many groups of two paths each: every group is + // replaced by its two paths, in order (in time linear in their number). + const PAIRS: u64 = 50_000; + let path = |id| Arc::new(Node::path(NodeId(id), Default::default(), Default::default())); + let pairs = (0..PAIRS).map(|i| Arc::new(Node::group(NodeId(1_000_000 + i), vec![path(2 * i + 1), path(2 * i + 2)]))).collect(); + let mut art = Arc::new(Node::group(NodeId(500_000), pairs)); + for i in 0..MAX_DEPTH - 3 { + art = Arc::new(Node::group(NodeId(600_000 + i as u64), vec![art])); + } + let mut d = doc(art); + assert!(d.limit_depth()); + assert!(!d.too_deep(None)); + let holder = d.node(NodeId(500_000)).and_then(Node::children).map(|c| c.iter().map(|c| c.id.0).collect::>()); + assert_eq!(holder, Some((1..=2 * PAIRS).collect())); + } + #[test] fn a_clipping_path_and_a_mask_too_deep_are_left_out_and_hidden_art_stays_hidden() { let mut inner = Node::new(NodeId(9000), NodeKind::Group { children: vec![nested(0), nested(0)], clip: true }); diff --git a/crates/doc/src/document_ext.rs b/crates/doc/src/document_ext.rs index b52e850c7..efd7f80b2 100644 --- a/crates/doc/src/document_ext.rs +++ b/crates/doc/src/document_ext.rs @@ -2,8 +2,8 @@ use super::graphic_style::GraphicStyle; use super::limits::MAX_NEXT_ID; use super::records::{ImageBlob, SavedSelection, SavedView}; use super::{ - Arc, Document, ImageObject, Node, NodeKind, SYMBOL_EXPORT_TYPE, SYMBOL_NINE_SLICE, SYMBOL_REGISTRATION, SymbolExportType, SymbolNineSlice, - SymbolRegistration, + Arc, Document, EnvelopeKind, ImageObject, LiveShape, Node, NodeKind, Point, SYMBOL_EXPORT_TYPE, SYMBOL_NINE_SLICE, SYMBOL_REGISTRATION, + SymbolExportType, SymbolNineSlice, SymbolRegistration, placed_document, }; impl Document { @@ -99,13 +99,15 @@ impl Document { } /// Leave opacity-mask editing and symbol editing: drop the temporary editing layer, a working - /// copy of art the mask or the symbol already holds (the engine syncs it after every edit). + /// copy of art the mask or the symbol already holds (the engine syncs it after every edit). The + /// layer is removed wherever it is, also as a sublayer of another layer; an id that names + /// anything other than a layer is left alone. pub fn drop_edit_modes(&mut self) { - if let Some(me) = self.mask_edit.take() { - self.layers.retain(|l| l.id != me.layer); - } - if let Some(se) = self.symbol_edit.take() { - self.layers.retain(|l| l.id != se.layer); + let layers = [self.mask_edit.take().map(|me| me.layer), self.symbol_edit.take().map(|se| se.layer)]; + for id in layers.into_iter().flatten() { + if self.node(id).is_some_and(Node::is_layer) { + let _ = self.remove(id); + } } } @@ -120,6 +122,112 @@ impl Document { d.drop_edit_modes(); std::borrow::Cow::Owned(d) } + + /// Round every path's anchors and handles to `decimals` decimal places: path objects (with + /// Live Corners' uncut outlines), blend spines, envelopes' top-object outlines and Intertwine + /// areas, in the layers, symbol definitions, graph designs, pattern swatches (the one being + /// edited included), opacity masks and the brushes kept as foreign data. Colors, transforms, + /// opacity, live shapes' own sizes, meshes and type (its frames and paths included) are left + /// as they are. + pub fn round_geometry(&mut self, decimals: u8) { + let scale = 10f64.powi(i32::from(decimals)); + let round = |v: f64| { + let r = (v * scale).round() / scale; + if !r.is_finite() { + v + } else if r == 0.0 { + // `-0.0` is written as `0.0`. + 0.0 + } else { + r + } + }; + let round_path = |path: &mut vectorcraft_geom::PathData| { + for a in path.subpaths.iter_mut().flat_map(|s| s.anchors.iter_mut()) { + for p in [&mut a.p, &mut a.h_in, &mut a.h_out] { + *p = Point::new(round(p.x), round(p.y)); + } + } + }; + let mut fix = |n: &mut Node| { + match &mut n.kind { + NodeKind::Path { path, live, .. } => { + round_path(path); + // Live Corners' uncut outline. + if let Some(LiveShape::Path { base, .. }) = live.as_deref_mut() { + round_path(base); + } + } + NodeKind::Blend { spec, .. } => { + if let Some(spine) = &mut spec.spine { + round_path(spine); + } + } + NodeKind::Envelope { kind, .. } => { + if let EnvelopeKind::TopObject { path } = kind.as_mut() { + round_path(path); + } + } + _ => {} + } + if let Some(e) = n.extras_if_any() { + for area in e.intertwine.as_deref_mut().into_iter().chain(e.intertwine_tiers.iter_mut()) { + round_path(area); + } + } + }; + let roots = self + .layers + .iter_mut() + .chain(self.symbols.iter_mut().map(|s| &mut s.art)) + .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) + .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) + .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())); + for root in roots { + placed_document::each_mut(Arc::make_mut(root), &mut fix); + } + if self.unknown.contains_key("brushes") + && let Some(brushes) = self.unknown.get_mut("brushes") + { + round_json_paths(brushes, &round); + } + } +} + +/// Round with `round` the anchors of the paths in `v`, nodes in their file form kept as JSON: every +/// `{"subpaths": [{"anchors": [{"p", "in", "out"}]}]}` outside type (`{"type": "text"}`), as +/// [`Document::round_geometry`] rounds the nodes it holds. +fn round_json_paths(v: &mut serde_json::Value, round: &impl Fn(f64) -> f64) { + use serde_json::Value; + match v { + Value::Object(o) => { + if o.get("type").and_then(Value::as_str) == Some("text") { + return; + } + if let Some(subpaths) = o.get_mut("subpaths").and_then(Value::as_array_mut) { + let anchors = subpaths.iter_mut().filter_map(|s| s.get_mut("anchors").and_then(Value::as_array_mut)).flatten(); + for a in anchors.filter_map(Value::as_object_mut) { + let points = a.iter_mut().filter(|(k, _)| matches!(k.as_str(), "p" | "in" | "out")).filter_map(|(_, v)| v.as_array_mut()); + for xy in points { + for n in xy.iter_mut().filter(|n| n.is_f64()) { + if let Some(r) = n.as_f64().and_then(|x| serde_json::Number::from_f64(round(x))) { + *n = Value::Number(r); + } + } + } + } + } + for c in o.values_mut() { + round_json_paths(c, round); + } + } + Value::Array(a) => { + for c in a { + round_json_paths(c, round); + } + } + _ => {} + } } /// Graphic styles. diff --git a/crates/doc/src/document_guides.rs b/crates/doc/src/document_guides.rs index 43e1c4c92..c24c18bc4 100644 --- a/crates/doc/src/document_guides.rs +++ b/crates/doc/src/document_guides.rs @@ -6,9 +6,15 @@ impl Document { /// Union of the geometric bounds of `ids` (their visual bounds when `visual`), with symbol /// instances measured by their art ([`Node::geometric_bounds_with`]). pub fn bounds_of(&self, ids: &[NodeId], visual: bool) -> Option { - let symbols = self.symbol_boxes(); + self.bounds_of_with(ids, visual, &self.symbol_boxes()) + } + + /// [`bounds_of`](Document::bounds_of) measuring symbol instances from `symbols`, a cache shared + /// across calls: measuring many instances one by one otherwise measures their art again for + /// each. + pub fn bounds_of_with(&self, ids: &[NodeId], visual: bool, symbols: &crate::SymbolBoxes) -> Option { ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| { - vectorcraft_geom::union_opt(acc, if visual { n.visual_bounds_with(&symbols) } else { n.geometric_bounds_with(&symbols) }) + vectorcraft_geom::union_opt(acc, if visual { n.visual_bounds_with(symbols) } else { n.geometric_bounds_with(symbols) }) }) } @@ -79,31 +85,77 @@ impl Document { /// by its clipping path where that paints). Guides (paths made into guides too) aren't artwork /// (Delete Empty Artboards). pub fn artboard_has_art(&self, rect: Rect) -> bool { + self.art_meets(&self.symbol_boxes(), rect, true) + } + + /// Is there a rect in `rects` (artboards) with no artwork on it, by [`Document::artboard_has_art`]? + /// The symbols are measured once for all of them. A first pass rules art out by a quick box + /// (paths and compound paths: their control points grown by the farthest their strokes reach), + /// so a rect away from every path is found without measuring along any; only when every rect + /// may hold art is each one measured. + pub fn any_artboard_without_art(&self, rects: impl IntoIterator) -> bool { + let rects: Vec = rects.into_iter().collect(); + let symbols = self.symbol_boxes(); + rects.iter().any(|r| !self.art_meets(&symbols, *r, false)) || rects.iter().any(|r| !self.art_meets(&symbols, *r, true)) + } + + /// [`Document::artboard_has_art`] of `rect` when `exact`. Otherwise whether art may lie on it: + /// paths and compound paths count when their quick box meets it, so a false answer is exact and + /// a true one may not be. + fn art_meets(&self, symbols: &SymbolBoxes, rect: Rect, exact: bool) -> bool { // As in `art_on_artboard`: a millionth of a point covers rounding at the edges. let rect = rect.inflate(1e-6, 1e-6); fn meets(b: Option, r: Rect) -> bool { b.is_some_and(|b| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1) } - fn has(c: &Node, s: &SymbolBoxes, rect: Rect) -> bool { + /// A box that holds the visual bounds of a path, or of a compound path of paths, found + /// without measuring along its shape: its control points' box grown by the farthest its + /// strokes reach ([`crate::Appearance::outset`], as in [`Node::reach_bounds`]), with a + /// margin for rounding. None for other objects, and when the reach is not finite. + fn quick_box(c: &Node) -> Option { + let b = match &c.kind { + NodeKind::Path { path, .. } => path.control_bounds()?, + NodeKind::Compound { children, .. } => children.iter().try_fold(None, |acc, m| { + let NodeKind::Path { path, .. } = &m.kind else { return None }; + Some(vectorcraft_geom::union_opt(acc, path.control_bounds())) + })??, + _ => return None, + }; + let o = c.appearance.outset(); + let o = o * (1.0 + 1e-9) + 1e-6; + o.is_finite().then(|| b.inflate(o, o)) + } + /// Does `c`'s visual box meet `rect`? Without `exact`, a quick box that meets it answers yes. + fn art_box_meets(c: &Node, s: &SymbolBoxes, rect: Rect, exact: bool) -> bool { + match quick_box(c).map(|b| meets(Some(b), rect)) { + Some(false) => false, + Some(true) if !exact => true, + _ => meets(c.visual_bounds_with(s), rect), + } + } + fn has(c: &Node, s: &SymbolBoxes, rect: Rect, exact: bool) -> bool { match &c.kind { NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { let Some((clip, rest)) = children.split_first() else { return false }; let paint = clip.clip_paint(); - if (paint.fill.is_some() || paint.stroke.is_some()) && meets(clip.visual_bounds_with(s), rect) { + if (paint.fill.is_some() || paint.stroke.is_some()) && art_box_meets(clip, s, rect, exact) { return true; } + if !exact { + // The part of `rect` inside the clip's box lies inside `rect`. + return rest.iter().any(|m| has(m, s, rect, false)); + } // The members show only inside the clip's box. let Some(b) = c.visual_bounds_with(s).filter(|b| meets(Some(*b), rect)) else { return false }; let inside = Rect::new(b.x0.max(rect.x0), b.y0.max(rect.y0), b.x1.min(rect.x1), b.y1.min(rect.y1)); - rest.iter().any(|m| has(m, s, inside)) + rest.iter().any(|m| has(m, s, inside, true)) } - NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().any(|m| has(m, s, rect)), + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().any(|m| has(m, s, rect, exact)), NodeKind::Path { guide: true, .. } => false, - _ => meets(c.visual_bounds_with(s), rect), + _ => art_box_meets(c, s, rect, exact), } } - let symbols = self.symbol_boxes(); - self.layers.iter().any(|l| has(l, &symbols, rect)) + self.layers.iter().any(|l| has(l, symbols, rect, exact)) } /// The art that moves, scales or is cut with artboard `index`: [`Document::art_on_artboard`] diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index 34c2a1dd9..b19adc8b0 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -314,6 +314,13 @@ pub struct SeriesPaint { /// Stroke weight in points, kept with [`Self::stroke`]. #[serde(skip_serializing_if = "Option::is_none")] pub stroke_width: Option, + /// Opacity (0..1) of the topmost fill of each path directly in the series group (graph designs keep their own), + /// taken from the topmost fill row edited on one of them. Absent: fully opaque. + #[serde(skip_serializing_if = "Option::is_none")] + pub fill_opacity: Option, + /// Opacity (0..1) of the topmost stroke of each path directly in the series group, as [`Self::fill_opacity`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub stroke_opacity: Option, } /// Graph data and options. `rows` are categories (one per row of the Graph Data window), each diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index d20c1ffe4..cb972b94f 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -577,7 +577,41 @@ fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols // (An envelope's content sits where it was, not where the envelope draws it.) edits_contents(c) || c.extras().shaper.is_some() && source_of(c, opt.scope) - || c.reach_bounds_with(symbols).is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p)) + || cull_reach(c, symbols).is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p)) +} + +/// A box round everything `c` can paint, for culling clicks: [`Node::reach_bounds_with`], except +/// that paths and compound paths are measured by their control points ([`PathData::control_bounds`], +/// which hold the curves) instead of along their curves, which a compound path of thousands of +/// members takes milliseconds to do on every click and hover. The box only rules points out, and it +/// is at least as large, so the same objects are hit. +/// +/// [`PathData::control_bounds`]: vectorcraft_geom::PathData::control_bounds +fn cull_reach(c: &Node, symbols: &SymbolBoxes) -> Option { + let control = match &c.kind { + NodeKind::Path { path, .. } => path.control_bounds(), + NodeKind::Compound { children, .. } if children.iter().all(|m| m.path_data().is_some()) => { + children.iter().filter_map(|m| m.path_data().and_then(vectorcraft_geom::PathData::control_bounds)).reduce(|a, b| a.union(b)) + } + _ => return c.reach_bounds_with(symbols), + }; + let o = c.appearance.outset(); + control.map(|b| b.inflate(o, o)) +} + +/// The members of compound path `children` that a click at `p` within `tol` can touch, as one +/// path: those whose control points' box, grown by `tol`, holds `p` (edges included). A member +/// outside that box is farther than `tol` from `p` and winds zero times round it, so this path's +/// outline and fill answer a click as the whole compound path's do, without assembling it. +fn compound_members_near(children: &[std::sync::Arc], p: Point, tol: f64) -> vectorcraft_geom::BezPath { + let near = |b: Rect| b.x0 - tol <= p.x && p.x <= b.x1 + tol && b.y0 - tol <= p.y && p.y <= b.y1 + tol; + let mut bp = vectorcraft_geom::BezPath::new(); + for pd in children.iter().filter_map(|m| m.path_data()) { + if pd.control_bounds().is_some_and(near) { + bp.extend(pd.to_bezpath()); + } + } + bp } /// Is the first child of `n` the isolated container `scope`? @@ -710,12 +744,23 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio } None } - NodeKind::Compound { rule, .. } => { - let bp = n.stroke_path()?; - if let Some(k) = hit_stroke(n, &bp, *rule, p, opt) { - return Some(k); + NodeKind::Compound { children, rule } => { + // Painted strokes follow the whole compound path (alignment, dashes, width profiles): + // they are measured along all of it, as before. + if !opt.outline && n.appearance.painted_strokes().next().is_some() { + let bp = n.stroke_path()?; + if n.appearance.stroke_hit(&bp, *rule, p, opt.tol) { + return Some(HitKind::Stroke); + } } - (!opt.outline && !opt.path_only && fill_contains(&bp, *rule, p)).then_some(HitKind::Fill) + // The outline and the fill only need the members near `p`. (Measured without + // `stroke_contains`'s box check: the box of the members near `p` is smaller than the + // whole path's, and its far edges would turn away points exactly `tol` away.) + let near = compound_members_near(children, p, opt.tol); + if vectorcraft_geom::hit::distance_to_outline(&near, p) <= opt.tol { + return Some(HitKind::Outline); + } + (!opt.outline && !opt.path_only && fill_contains(&near, *rule, p)).then_some(HitKind::Fill) } // The type path lies within the bounds: they rule out the rest before it is measured. NodeKind::Text(t) if opt.type_path_only => { @@ -835,6 +880,16 @@ pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option { /// clipping mask counts only when the marquee meets the region its clipping path clips to, and then /// only within that region's bounds. pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> Vec { + marquee_with(doc, r, scope, leaves, false) +} + +/// Candidates for whole-object enclosure by a loop bounded by `r`. Eligibility and group +/// traversal follow [`marquee`]; compounds contribute the complete path they paint. +pub fn enclosure_candidates(doc: &Document, r: Rect, scope: Option) -> Vec { + marquee_with(doc, r, scope, true, true) +} + +fn marquee_with(doc: &Document, r: Rect, scope: Option, leaves: bool, whole_compounds: bool) -> Vec { let mut out = Vec::new(); // `tops` pairs each object with the part of `r` in which the marquee can touch it. let tops: Vec<(&std::sync::Arc, Rect)> = match scope.and_then(|s| doc.node(s)) { @@ -879,13 +934,19 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> /// ([`shaper_hides`]). An Intertwine group is touched by its members, and a repeat is touched /// by the copies it draws, as clicks hit them. Objects of every other kind except paths, /// compound paths, layers and groups are touched by their box. - fn touches(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option) -> bool { + fn touches(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option, whole_compounds: bool) -> bool { match &n.kind { NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r), - NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => marquee_in(n, r) - .is_some_and(|r| children.iter().enumerate().any(|(i, c)| c.visible && !shaper_hides(n, i, scope) && touches(c, s, r, scope))), - NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope)), - NodeKind::Repeat(spec) => spec.expand(Some(s.document())).iter().any(|copy| touches(copy, s, r, scope)), + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => { + marquee_in(n, r).is_some_and(|r| { + children.iter().enumerate().any(|(i, c)| c.visible && !shaper_hides(n, i, scope) && touches(c, s, r, scope, whole_compounds)) + }) + } + NodeKind::Compound { children, .. } if whole_compounds => { + children.iter().filter_map(|c| c.path_data()).any(|path| vectorcraft_geom::hit::intersects_rect(path, r)) + } + NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope, whole_compounds)), + NodeKind::Repeat(spec) => spec.expand(Some(s.document())).iter().any(|copy| touches(copy, s, r, scope, whole_compounds)), // A box with no width or no height (an instance of a symbol that draws a straight // line) is touched where the marquee crosses it. _ => n.geometric_bounds_with(s).is_some_and(|b| { @@ -894,18 +955,18 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> }), } } - fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option, out: &mut Vec) { + fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option, whole_compounds: bool, out: &mut Vec) { match &n.kind { NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => { let Some(r) = marquee_in(n, r) else { return }; for (i, c) in children.iter().enumerate() { if c.visible && !c.locked && !shaper_hides(n, i, scope) { - collect_leaves(c, s, r, scope, out); + collect_leaves(c, s, r, scope, whole_compounds, out); } } } _ => { - if touches(n, s, r, scope) { + if touches(n, s, r, scope, whole_compounds) { out.push(n.id); } } @@ -918,8 +979,8 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> continue; } if leaves { - collect_leaves(t, &symbols, r, scope, &mut out); - } else if touches(t, &symbols, r, scope) { + collect_leaves(t, &symbols, r, scope, whole_compounds, &mut out); + } else if touches(t, &symbols, r, scope, whole_compounds) { out.push(t.id); } } diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index c4d6c565e..452055a8f 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -1120,3 +1120,223 @@ fn visible_sampling_reads_locked_art_and_layers_but_excludes_hidden_art() { d.node_mut(l).unwrap().visible = false; assert!(hit_test_visible(&d, p, HitOptions::default()).is_none()); } + +/// Deterministic xorshift in 0..1. +struct Rng(u64); +impl Rng { + fn next(&mut self) -> f64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 % 1_000_000) as f64 / 1_000_000.0 + } +} + +/// A closed blob of `n` smooth anchors round `c` (radius about `r`), turning either way, with +/// handles long enough that its control points reach past its curves. +fn blob(rng: &mut Rng, c: Point, r: f64, n: usize, clockwise: bool) -> vectorcraft_geom::SubPath { + use vectorcraft_geom::{Anchor, Vec2}; + let anchors = (0..n) + .map(|i| { + let k = if clockwise { i as f64 } else { (n - i) as f64 }; + let a = k / n as f64 * std::f64::consts::TAU; + let rr = r * (0.6 + 0.8 * rng.next()); + let p = c + Vec2::new(a.cos(), a.sin()) * rr; + let t = Vec2::new(-a.sin(), a.cos()) * (if clockwise { 1.0 } else { -1.0 }) * rr * (0.2 + 1.3 * rng.next()); + Anchor::smooth(p, p + t) + }) + .collect(); + vectorcraft_geom::SubPath::new(anchors, true) +} + +/// A document with one compound path of `members` blobs (some of them holes), its id and the +/// members' paths. +fn compound_doc(members: usize, rule: vectorcraft_geom::FillRule, appearance: Appearance) -> (Document, NodeId, Vec) { + let mut d = Document::new(800.0, 800.0); + let l = d.layers[0].id; + let mut rng = Rng(0x9e37_79b9_7f4a_7c15); + let mut paths = vec![]; + let mut children = vec![]; + for i in 0..members { + let c = Point::new(20.0 + rng.next() * 600.0, 20.0 + rng.next() * 600.0); + let r = 3.0 + rng.next() * 9.0; + let pd = vectorcraft_geom::PathData::single(blob(&mut rng, c, r, 3 + i % 4, i % 3 != 0)); + children.push(Arc::new(Node::path(d.alloc_id(), pd.clone(), Appearance::default()))); + paths.push(pd); + } + let id = d.alloc_id(); + let mut compound = Node::new(id, NodeKind::Compound { children, rule }); + compound.appearance = appearance.into(); + d.insert(Some(l), 0, compound).unwrap(); + (d, id, paths) +} + +/// What a click on compound path `n` gave before compound paths were culled by their control +/// points and their members near the click: the whole path assembled (`full`), culled by its +/// reach measured along the curves. +fn compound_hit_before(n: &Node, full: &vectorcraft_geom::BezPath, p: Point, opt: HitOptions) -> Option { + let NodeKind::Compound { rule, .. } = &n.kind else { return None }; + if !n.reach_bounds().is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p)) { + return None; + } + if !opt.outline && n.appearance.stroke_hit(full, *rule, p, opt.tol) { + return Some(HitKind::Stroke); + } + if stroke_contains(full, 0.0, opt.tol, p) { + return Some(HitKind::Outline); + } + (!opt.outline && !opt.path_only && fill_contains(full, *rule, p)).then_some(HitKind::Fill) +} + +/// The points a test clicks for `paths`: random ones over them and past them, and, for some +/// members, their anchors, points just over the tolerance from them, and the corners and edge +/// midpoints of their control and curve boxes, on them and grown by the tolerance. +fn probe_points(paths: &[vectorcraft_geom::PathData], tol: f64, random: usize, every: usize) -> Vec { + let all = paths.iter().filter_map(vectorcraft_geom::PathData::control_bounds).reduce(|a, b| a.union(b)).unwrap().inflate(30.0, 30.0); + let mut rng = Rng(7); + let mut pts: Vec = (0..random).map(|_| Point::new(all.x0 + rng.next() * all.width(), all.y0 + rng.next() * all.height())).collect(); + for pd in paths.iter().step_by(every) { + for a in pd.subpaths.iter().flat_map(|sp| &sp.anchors) { + pts.extend([a.p, Point::new(a.p.x + tol, a.p.y), Point::new(a.p.x, a.p.y - tol), Point::new(a.p.x + tol * 1.0001, a.p.y)]); + } + for b in [pd.control_bounds().unwrap(), pd.bounds().unwrap()] { + for b in [b, b.inflate(tol, tol)] { + let (cx, cy) = (b.center().x, b.center().y); + pts.extend( + [(b.x0, b.y0), (b.x1, b.y0), (b.x1, b.y1), (b.x0, b.y1), (cx, b.y0), (cx, b.y1), (b.x0, cy), (b.x1, cy)] + .map(|(x, y)| Point::new(x, y)), + ); + } + } + } + pts +} + +/// A compound path of thousands of members hits exactly as it did when it was assembled +/// whole for every click: at random points, on and next to anchors, and on the edges of the +/// members' boxes (control points' and curves', with and without the tolerance), for both fill +/// rules, in outline mode and with Object Selection by Path Only. +#[test] +fn culled_compound_hits_as_the_whole_path() { + use vectorcraft_geom::FillRule; + for (rule, opt) in [ + (FillRule::NonZero, HitOptions::default()), + (FillRule::EvenOdd, HitOptions { tol: 0.5, ..Default::default() }), + (FillRule::NonZero, HitOptions { outline: true, tol: 1.0, ..Default::default() }), + (FillRule::EvenOdd, HitOptions { path_only: true, ..Default::default() }), + ] { + let (d, id, paths) = compound_doc(2000, rule, Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let n = d.node(id).unwrap(); + let full = n.stroke_path().unwrap(); + // The whole path's box grown by the tolerance. Before, a point on its far edges (right + // and bottom: `Rect::contains` leaves them out) never hit, even exactly `tol` from the + // outline; now it hits there as on the near edges. Those points are clicked too, and + // checked against the distance alone. + let far = vectorcraft_geom::Shape::bounding_box(&full).inflate(opt.tol, opt.tol); + let mut pts = probe_points(&paths, opt.tol, 200, 120); + let rightmost = paths.iter().filter_map(vectorcraft_geom::PathData::bounds).max_by(|a, b| a.x1.total_cmp(&b.x1)).unwrap(); + pts.push(Point::new(far.x1, rightmost.center().y)); + let (mut hits, mut kinds) = (0, HashSet::new()); + for p in pts { + let before = compound_hit_before(n, &full, p, opt); + let now = hit_test(&d, p, opt); + if p.x == far.x1 || p.y == far.y1 { + let outline = vectorcraft_geom::hit::distance_to_outline(&full, p) <= opt.tol; + assert_eq!(now.is_some(), outline || before.is_some(), "{rule:?} {opt:?} on the far edge at {p:?}"); + continue; + } + assert_eq!(now.as_ref().map(|h| (h.leaf, h.kind)), before.map(|k| (id, k)), "{rule:?} {opt:?} at {p:?}"); + hits += usize::from(before.is_some()); + kinds.extend(before.map(|k| format!("{k:?}"))); + } + // The points reach both hits and misses, and the outline as well as the fill. + assert!(hits > 100, "{rule:?} {opt:?}: {hits} hits"); + assert!(kinds.contains("Outline"), "{rule:?} {opt:?}: {kinds:?}"); + } +} + +/// A wide stroke paints far past a path's control points: clicks on it there still hit, for a +/// path and for a compound path (whose strokes are measured along all of it), exactly as when +/// they were culled by their reach measured along the curves. +#[test] +fn wide_strokes_hit_past_the_control_points() { + use vectorcraft_geom::{Anchor, PathData, SubPath}; + let mut d = Document::new(400.0, 400.0); + let l = d.layers[0].id; + let stroke = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 40.0); + // A zigzag 10 pt high whose 40 pt stroke reaches 20 pt (more at its mitred turns) past it. + let zig = PathData::single(SubPath::new( + [(100.0, 100.0), (110.0, 110.0), (120.0, 100.0), (130.0, 110.0)].map(|(x, y)| Anchor::corner(Point::new(x, y))).to_vec(), + false, + )); + let path = d.alloc_id(); + d.insert(Some(l), 0, Node::path(path, zig.clone(), stroke.clone())).unwrap(); + // A compound path of two small rings with the same stroke, further along. + let mut rng = Rng(3); + let members: Vec> = [Point::new(250.0, 250.0), Point::new(262.0, 250.0)] + .into_iter() + .map(|c| Arc::new(Node::path(d.alloc_id(), PathData::single(blob(&mut rng, c, 4.0, 4, true)), Appearance::default()))) + .collect(); + let compound = d.alloc_id(); + let mut node = Node::new(compound, NodeKind::Compound { children: members, rule: vectorcraft_geom::FillRule::NonZero }); + node.appearance = stroke.into(); + d.insert(Some(l), 9, node).unwrap(); + let symbols = d.symbol_boxes(); + let opt = HitOptions::default(); + for id in [path, compound] { + let n = d.node(id).unwrap(); + let full = n.stroke_path().unwrap(); + let control = match &n.kind { + NodeKind::Path { path, .. } => path.control_bounds().unwrap(), + _ => n.children().unwrap().iter().filter_map(|m| m.path_data()?.control_bounds()).reduce(|a, b| a.union(b)).unwrap(), + }; + let (mut outside, mut checked) = (0, 0); + let area = control.inflate(40.0, 40.0); + for i in 0..=80 { + for j in 0..=80 { + let p = Point::new(area.x0 + area.width() * f64::from(i) / 80.0, area.y0 + area.height() * f64::from(j) / 80.0); + let before = match &n.kind { + NodeKind::Path { .. } => n + .reach_bounds_with(&symbols) + .is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p)) + .then(|| hit_leaf(n, p, opt, &symbols)) + .flatten(), + _ => compound_hit_before(n, &full, p, opt), + }; + let now = hit_test(&d, p, opt).filter(|h| h.leaf == id).map(|h| h.kind); + assert_eq!(now, before, "{id:?} at {p:?}"); + checked += 1; + outside += usize::from(before == Some(HitKind::Stroke) && !control.contains(p)); + } + } + assert!(outside > 50, "{id:?}: {outside} of {checked} points hit the stroke outside the control points' box"); + } +} + +/// The box a click is culled by holds the object's reach for paths and compound paths (it is +/// measured by their control points, which hold their curves, and grown by the same stroke reach), +/// so culling by it never turns away a click the reach let through. (Up to rounding: where a curve's +/// extreme is an anchor, the two boxes measure the same point in different ways.) +#[test] +fn cull_box_holds_the_reach_of_paths_and_compound_paths() { + const E: f64 = 1e-9; + let holds = + |outer: Rect, inner: Rect| outer.x0 - E <= inner.x0 && outer.y0 - E <= inner.y0 && inner.x1 <= outer.x1 + E && inner.y1 <= outer.y1 + E; + let stroke = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 12.0); + let (mut d, id, _) = compound_doc(200, vectorcraft_geom::FillRule::NonZero, stroke.clone()); + let path_id = d.alloc_id(); + let symbols = d.symbol_boxes(); + let n = d.node(id).unwrap(); + let mut checked = 0; + for node in std::iter::once(n).chain(n.children().unwrap().iter().map(|m| &**m)) { + let (cull, reach) = (cull_reach(node, &symbols).unwrap(), node.reach_bounds_with(&symbols).unwrap()); + assert!(holds(cull, reach), "{:?}: {cull:?} does not hold {reach:?}", node.id); + checked += 1; + } + // A stroked plain path, its handles reaching past its curves. + let mut rng = Rng(11); + let path = Node::path(path_id, vectorcraft_geom::PathData::single(blob(&mut rng, Point::new(50.0, 50.0), 20.0, 5, true)), stroke); + let (cull, reach) = (cull_reach(&path, &symbols).unwrap(), path.reach_bounds_with(&symbols).unwrap()); + assert!(holds(cull, reach), "{cull:?} does not hold {reach:?}"); + assert_eq!(checked, 201); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c836390e1..2a51b662b 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -29,7 +29,10 @@ mod symbol_registration; pub use inline::{SYMBOL_HALF, SYMBOL_SIZES}; pub use shared::Shared; pub use symbol_export_type::{SYMBOL_EXPORT_TYPE, SymbolExportType}; -pub use symbol_nine_slice::{MAX_SLICE_GROWTH, NineSliceMap, PAINTED_EFFECTS, SYMBOL_NINE_SLICE, SymbolNineSlice, charge_symbol_art}; +pub use symbol_nine_slice::{ + EditedNineSlice, MAX_SLICE_GROWTH, MIN_CENTER_BAND, NineSliceGuide, NineSliceMap, PAINTED_EFFECTS, SYMBOL_NINE_SLICE, SymbolNineSlice, + charge_symbol_art, +}; pub use symbol_overrides::SymbolOverride; pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration}; pub mod links; @@ -37,6 +40,7 @@ pub mod live; pub mod live_paint; pub mod marks; mod mem; +pub use mem::{appearance_heap_bytes, paint_heap_bytes}; pub mod metadata; pub mod node; pub mod objects_on_path; @@ -64,6 +68,7 @@ mod symbols; pub use symbols::SymbolBoxes; pub mod text; pub mod trace; +pub mod twist; pub mod variables; pub mod xml_id; @@ -107,6 +112,7 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes}; pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource}; pub use style_libs::StyleLibrary; pub use text::FontHeightReference; +pub use text::GlyphChoice; pub use text::{ AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition, @@ -223,8 +229,9 @@ pub struct Document { #[serde(default, skip_serializing)] pub mask_edit: Option, /// Symbol editing mode, while active. Never saved (see [`Document::without_edit_modes`]): the - /// symbol already holds the art of its editing layer. - #[serde(default, skip_serializing)] + /// symbol already holds the art of its editing layer. Never read either: a value in file data + /// is dropped, so the layer it names is kept and saving does not write the value again. + #[serde(default, skip_serializing, deserialize_with = "skip::read_as_none")] pub symbol_edit: Option, next_id: u64, /// Foreign data preserved on round-trip. diff --git a/crates/doc/src/limits.rs b/crates/doc/src/limits.rs index 027148ade..86c2e802a 100644 --- a/crates/doc/src/limits.rs +++ b/crates/doc/src/limits.rs @@ -13,7 +13,9 @@ pub enum DocError { Invalid(String), } -pub const FORMAT_VERSION: u32 = 1; +/// The document version new documents get. Version 2: a positive Twist angle turns clockwise +/// ([`crate::twist`]). +pub const FORMAT_VERSION: u32 = 2; /// Node, slice and asset ids are handed out below this: no document gets near it by counting, so /// larger ids in a file are damage (or made by hand) and the counter never follows them (#1718). diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs index a1268505f..a93830822 100644 --- a/crates/doc/src/mem.rs +++ b/crates/doc/src/mem.rs @@ -98,7 +98,7 @@ impl Document { vec_bytes(g) + g.iter().map(|g: &SwatchGroup| g.name.capacity() + swatches_bytes(&g.swatches)).sum::() }); n += shared(&self.graphic_styles, &newer.graphic_styles, |g| { - vec_bytes(g) + g.iter().map(|g: &GraphicStyle| g.name.capacity() + appearance_heap(&g.appearance)).sum::() + vec_bytes(g) + g.iter().map(|g: &GraphicStyle| g.name.capacity() + appearance_heap_bytes(&g.appearance)).sum::() }); let styles = |s: &Vec| vec_bytes(s) + s.iter().map(|s| s.name.capacity() + map_bytes(&s.attrs)).sum::(); n += shared(&self.char_styles, &newer.char_styles, styles); @@ -245,7 +245,7 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize { let mut b = ALLOC + ARC + size_of::(); b += n.name.as_ref().map_or(0, String::capacity); if !other.is_some_and(|m| Shared::ptr_eq(&m.appearance, &n.appearance)) { - b += ALLOC + ARC + appearance_heap(&n.appearance); + b += ALLOC + ARC + appearance_heap_bytes(&n.appearance); } if let Some(e) = n.extras_box() { b += ALLOC + size_of::(); @@ -293,32 +293,53 @@ fn text_bytes(t: &TextObject) -> usize { + vec_bytes(&t.cached_baselines) } -/// The heap an appearance owns (not the appearance itself). -fn appearance_heap(a: &Appearance) -> usize { +/// Approximate retained memory of an appearance and its owned buffers, without serialization. +pub fn appearance_heap_bytes(a: &Appearance) -> usize { size_of::() + vec_bytes(&a.items) + a.items .iter() - .map(|i| match i { - AppearanceItem::Fill(f) => paint_heap(&f.paint), - AppearanceItem::Stroke(s) => paint_heap(&s.paint), + .map(|item| { + let paint = paint_heap_bytes(item.paint()); + let effects = effects_heap(item.effects()); + let stroke = match item { + AppearanceItem::Stroke(s) => { + s.dash.as_ref().map_or(0, |d| vec_bytes(&d.pattern)) + + s.profile.as_ref().map_or(0, |p| vec_bytes(&p.points)) + + s.pressure.as_ref().map_or(0, |p| vec_bytes(&p.points)) + + s.brush.as_ref().map_or(0, String::capacity) + } + _ => 0, + }; + paint + effects + stroke }) .sum::() - + vec_bytes(&a.effects) - + a.effects.iter().map(|e: &Effect| e.id.capacity() + json_bytes(&e.params)).sum::() + + effects_heap(&a.effects) } -fn paint_heap(p: &Paint) -> usize { +fn effects_heap(effects: &Vec) -> usize { + vec_bytes(effects) + effects.iter().map(|e| e.id.capacity() + json_bytes(&e.params)).sum::() +} + +/// Approximate heap retained by one paint, without serializing it. +pub fn paint_heap_bytes(p: &Paint) -> usize { match p { Paint::Solid { swatch, .. } => swatch.as_ref().map_or(0, String::capacity), - Paint::Gradient(g) => ALLOC + size_of_val(&**g) + vec_bytes(&g.gradient.stops), + Paint::Gradient(g) => { + ALLOC + + size_of_val(&**g) + + vec_bytes(&g.gradient.stops) + + g.gradient.stops.iter().map(|s| s.swatch.as_ref().map_or(0, String::capacity)).sum::() + + g.swatch.as_ref().map_or(0, String::capacity) + + g.freeform.as_ref().map_or(0, |f| vec_bytes(&f.points) + vec_bytes(&f.lines) + f.lines.iter().map(vec_bytes).sum::()) + } Paint::Pattern { pattern, .. } => pattern.capacity(), Paint::None => 0, } } fn swatches_bytes(s: &[Swatch]) -> usize { - ALLOC + std::mem::size_of_val(s) + s.iter().map(|w| w.name.capacity() + paint_heap(&w.paint)).sum::() + ALLOC + std::mem::size_of_val(s) + s.iter().map(|w| w.name.capacity() + paint_heap_bytes(&w.paint)).sum::() } /// A vector's buffer (not what its elements own). diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index b5ae03e8b..f31f942ce 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -185,8 +185,13 @@ impl Node { match &self.kind { NodeKind::Path { path, .. } => Some(path.to_bezpath()), NodeKind::Compound { children, .. } => { - let mut bp = vectorcraft_geom::BezPath::new(); - children.iter().filter_map(|c| c.path_data()).for_each(|pd| bp.extend(pd.to_bezpath())); + // Sized up front: grown by doubling, the path of a compound path of hundreds of + // thousands of anchors is copied into ever larger buffers, up to tens of MB, on + // every call. + let subpaths = || children.iter().filter_map(|c| c.path_data()).flat_map(|pd| &pd.subpaths); + let elements = subpaths().filter(|sp| !sp.anchors.is_empty()).map(|sp| 1 + sp.segment_count() + usize::from(sp.closed)).sum(); + let mut bp = vectorcraft_geom::BezPath::from_vec(Vec::with_capacity(elements)); + subpaths().for_each(|sp| sp.to_bezpath_into(&mut bp)); Some(bp) } _ => None, @@ -207,7 +212,10 @@ impl Node { /// [`Node::reach_bounds`] with symbol instances measured by their symbol's art box from /// `symbols`. An instance that only moves its art is measured as the art's visual box moved, /// and any other instance is measured as its geometric box ([`Node::geometric_bounds_with`]) - /// grown by the farthest a stroke in the art can reach. + /// grown by the farthest a stroke in the art can reach. For an instance placed through 9-slice + /// guides, that box also holds the art box placed by each cell's map + /// ([`crate::NineSliceMap::placement_bounds`]). An instance whose art holds an instance of a + /// symbol with 9-slice guides at any depth is measured as its art placed by its transform. pub fn reach_bounds_with(&self, symbols: &SymbolBoxes) -> Option { symbols.quickly(|| self.reach(Some(symbols))) } @@ -362,9 +370,14 @@ impl Node { Some((_, art)) if moves_only(*xf) => xf.transform_rect_bbox(art.visual), Some((s, art)) => { let placed = xf.transform_rect_bbox(art.geometric); - match s.placed_visual(symbol, *xf) { + match s.placed_visual(symbol, *xf).or_else(|| s.sliced_visual(symbol, *xf)) { Some(v) => v.union(placed), - None => placed.inflate(art.reach, art.reach), + // Art placed through 9-slice guides can draw outside `placed`. + None => s + .document() + .symbol_slice_map_of(symbol, art.geometric, *xf) + .map_or(placed, |m| m.placement_bounds(art.geometric).union(placed)) + .inflate(art.reach, art.reach), } } None => xf.transform_rect_bbox(SYMBOL_SQUARE), @@ -393,6 +406,12 @@ impl Node { self.transform_scaled(a, &scaling.into()); } + /// The path of the child an Objects on Path group's objects are attached to. + fn on_path_data(&self) -> Option<&PathData> { + let r = self.extras().objects_on_path.as_deref()?; + self.children()?.iter().find(|c| c.id == r.path)?.path_data() + } + fn transform_scaled(&mut self, a: Affine, sc: &Scaling) { // A dimension's measurement moves with it. if let Some(e) = self.extras_if_any() @@ -400,12 +419,8 @@ impl Node { { dimension.transform(a); } - // The frames of the objects attached to a path move with them. - if let Some(e) = self.extras_if_any() - && let Some(on_path) = &mut e.objects_on_path - { - on_path.transform(a); - } + // The path the objects are attached to, before it is transformed. + let on_path_before = self.on_path_data().cloned(); if !self.is_layer() { self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a); } @@ -496,6 +511,14 @@ impl Node { NodeKind::Mesh(m) => m.transform(a), NodeKind::Repeat(r) => r.transform(a, *sc), } + // The frames of the objects attached to a path move with them, and the distances along the + // path follow the path as it is now (a live shape's path is regenerated). + let on_path_after = on_path_before.as_ref().and_then(|_| self.on_path_data().cloned()); + if let Some(e) = self.extras_if_any() + && let Some(on_path) = &mut e.objects_on_path + { + on_path.transform(a, on_path_before.as_ref().zip(on_path_after.as_ref())); + } if let Some(m) = self.extras_if_any().and_then(|e| e.mask.as_mut()) && m.linked { @@ -583,6 +606,25 @@ impl Node { } } + /// The paint behind the Fill (or Stroke) proxy, the one [`Node::proxy_paint`] gives, without its + /// map and box: the object is not measured, so a long path costs no more than a short one. + pub fn proxy_paint_only(&self, stroke: bool, item: Option) -> Option<&vectorcraft_color::Paint> { + let item = self.appearance.item_of_kind(item, !stroke); + if item.is_none() + && !stroke + && let Some(c) = self.extras().colorize.as_deref() + { + return Some(c); + } + if item.is_none() + && let NodeKind::Text(t) = &self.kind + { + let st = &t.runs.first()?.style; + return Some(if stroke { &st.stroke } else { &st.fill }); + } + if stroke { self.appearance.stroke_at(item).map(|s| &s.paint) } else { self.appearance.fill_at(item).map(|f| &f.paint) } + } + /// The gradient behind the Fill (or Stroke) proxy and its placement in document coordinates /// (see [`Node::proxy_paint`]): what the gradient annotator shows and edits. pub fn proxy_gradient(&self, stroke: bool, item: Option) -> Option<(&vectorcraft_color::GradientPaint, vectorcraft_color::GradientGeom)> { diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index df675580e..cde4004ae 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -241,6 +241,34 @@ fn uneven_scales_keep_live_corners_circular() { assert_round_corners(&n, Affine::IDENTITY, 40.0); } +/// Objects attached to a rounded rectangle that keeps its corners when scaled stay where the scale +/// takes them on the regenerated path, which is not twice as long when twice the size. +#[test] +fn objects_on_a_live_rectangle_follow_its_regenerated_path() { + use crate::objects_on_path::{CENTER, ObjectsOnPath, stops}; + let path = live_rect(100.0, 100.0, 20.0, Affine::IDENTITY); + let objects: Vec = + (2..5).map(|i| Node::path(NodeId(i), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Default::default())).collect(); + let members: Vec<&Node> = objects.iter().collect(); + let rec = ObjectsOnPath { spacing: Some(20.0), start: 10.0, ..ObjectsOnPath::new(NodeId(1), &members, CENTER, 0.0) }; + let mut children = vec![Arc::new(path)]; + children.extend(objects.iter().cloned().map(Arc::new)); + let mut g = Node::new(NodeId(9), NodeKind::Group { children, clip: false }); + g.extras_mut().objects_on_path = Some(Box::new(rec)); + let path_of = |g: &Node| g.children().and_then(|c| c.first()).and_then(|p| p.path_data().cloned()).unwrap(); + let rec_of = |g: &Node| g.extras().objects_on_path.as_deref().cloned().unwrap(); + let r = rec_of(&g); + let before = stops(&path_of(&g), 3, r.spacing, r.start).unwrap(); + let a = Affine::scale(2.0); + g.transform(a, KEEP_CORNERS); + assert_round_corners(&g.children().unwrap()[0], Affine::IDENTITY, 20.0); + let r = rec_of(&g); + let after = stops(&path_of(&g), 3, r.spacing, r.start).unwrap(); + for ((p, _), (q, _)) in before.iter().zip(&after) { + assert!((a * *p).distance(*q) < 1e-6, "{p:?} went to {q:?}: {r:?}"); + } +} + #[test] fn rotated_and_reflected_live_rectangles_scale_along_their_own_sides() { let place = Affine::translate((200.0, 100.0)) * Affine::rotate(30f64.to_radians()); @@ -362,10 +390,19 @@ fn a_projection_follows_the_transform_or_is_removed() { let mut n = instance(far); n.transform(Affine::scale(1e150), false); assert_eq!(projection(&n), None); - // A transform that is not invertible leaves the projection as it is. + // A transform that is not invertible leaves the projection as it is, at any scale. let mut n = instance(far); n.transform(Affine::scale(0.0), false); assert_eq!(projection(&n), Some(far.to_array())); + let mut n = instance(far); + n.transform(Affine::new([1e-8, 2e-8, 2e-8, 4e-8, 0.0, 0.0]), false); + assert_eq!(projection(&n), Some(far.to_array())); + // A scale by 1e-8 is invertible: a move by 10 becomes a move by 1e-7. + let ten = Homography::from_affine(Affine::translate((10.0, 0.0))); + let mut n = instance(ten); + n.transform(Affine::scale(1e-8), false); + let q = n.projection().unwrap().apply(Point::new(3e-8, 7e-8)).unwrap(); + assert!(q.distance(Point::new(1.3e-7, 7e-8)) < 1e-20, "{q:?}"); } #[test] @@ -518,3 +555,23 @@ fn fields_before_the_type_are_buffered_within_the_value_budget() { assert!(with_buffered_value_budget(0, || read(&written)).is_ok(), "a saved object buffers nothing: {written}"); assert!(read(reordered).is_ok(), "no budget outside a read"); } + +/// A compound path's path is its members' paths one after another, open and closed, empty +/// members and single anchors included. +#[test] +fn compound_stroke_path_is_its_members_in_order() { + use vectorcraft_geom::{Anchor, PathData, SubPath}; + let open = PathData::single(SubPath::new([(0.0, 0.0), (5.0, 1.0), (9.0, 7.0)].map(|(x, y)| Anchor::corner(Point::new(x, y))).to_vec(), false)); + let lone = PathData::single(SubPath::new(vec![Anchor::corner(Point::new(3.0, 3.0))], false)); + let members = + [shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), open, PathData::default(), lone, shapes::ellipse(Rect::new(10.0, 10.0, 20.0, 16.0))]; + let mut expected = vectorcraft_geom::BezPath::new(); + for pd in &members { + expected.extend(pd.to_bezpath()); + } + let children = members.iter().enumerate().map(|(i, pd)| Arc::new(Node::path(NodeId(i as u64 + 1), pd.clone(), Appearance::default()))).collect(); + let n = Node::new(NodeId(99), NodeKind::Compound { children, rule: vectorcraft_geom::FillRule::EvenOdd }); + let bp = n.stroke_path().unwrap(); + assert_eq!(bp.elements(), expected.elements()); + assert!(!bp.elements().is_empty()); +} diff --git a/crates/doc/src/objects_on_path.rs b/crates/doc/src/objects_on_path.rs index 779e2eced..67c9c0ee8 100644 --- a/crates/doc/src/objects_on_path.rs +++ b/crates/doc/src/objects_on_path.rs @@ -5,8 +5,9 @@ //! the other children are the attached objects, kept where they are drawn: the group draws, hits, //! saves and exports as a group. The record lists the objects in their order along the path, each //! with the frame it is drawn in and the angle that frame is turned by. [`place`] spreads them -//! evenly along the path from its first anchor (the first object sits on that anchor, and the last -//! one sits on the end of an open path), puts the attachment point of each one +//! along the path ([`stops`]): evenly from its first anchor by default (the first object sits on +//! that anchor, and the last one sits on the end of an open path), or [`ObjectsOnPath::spacing`] +//! apart from [`ObjectsOnPath::start`]. It puts the attachment point of each one //! ([`ObjectsOnPath::pivot`]) on the path, and turns its frame to the path's direction there, //! tilted by [`ObjectsOnPath::rotate`]. //! @@ -45,6 +46,14 @@ pub struct ObjectsOnPath { /// upright object has its frame's x axis along the path's direction at its attachment point. #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub rotate: f64, + /// Space: the distance along the path (points) from each object's attachment point to the + /// next one's. `None` (Auto) spreads the objects evenly ([`layout`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spacing: Option, + /// The distance along the path (points) from its first anchor to the first object's + /// attachment point. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub start: f64, } /// An object attached to the path of an Objects on Path group. @@ -95,19 +104,38 @@ impl ObjectsOnPath { /// in its frame as drawn ([`upright_frame`]). pub fn new(path: NodeId, members: &[&Node], pivot: u8, rotate: f64) -> Self { let members = members.iter().map(|n| Member::new(n)).collect(); - Self { path, members, pivot: pivot.min(8), rotate: if rotate.is_finite() { rotate } else { 0.0 } } + Self { path, members, pivot: pivot.min(8), rotate: if rotate.is_finite() { rotate } else { 0.0 }, spacing: None, start: 0.0 } } /// The frames go with the group, and each frame's angle becomes the transformed path's /// direction at the object plus the tilt, so that [`place`] keeps each object's scale, shear /// and reflection when it spreads the objects along the transformed path again. A reflection - /// also reverses Rotate: the objects keep the mirrored tilt to the mirrored path. - pub fn transform(&mut self, a: Affine) { + /// also reverses Rotate: the objects keep the mirrored tilt to the mirrored path. With `paths`, + /// the group's path before the transform and as it is after it, the start and a Space value + /// keep the first and last objects where the transform takes them on the path; a value that + /// cannot be followed that way scales by how much the path's length changed. Without `paths`, + /// they scale by the transform's average scale (the square root of its area scale). A + /// value that would not be finite once scaled stays as it is. + pub fn transform(&mut self, a: Affine, paths: Option<(&PathData, &PathData)>) { let before = tilt(self.rotate); + let [a0, a1, a2, a3, _, _] = a.as_coeffs(); if a.determinant() < 0.0 && self.rotate != 0.0 { self.rotate = -self.rotate; } + let along = paths.and_then(|(old, new)| { + let s = path_length(new)?.0 / path_length(old)?.0; + (s.is_finite() && s > 0.0).then_some(s) + }); + let scale = along.unwrap_or_else(|| a.determinant().abs().sqrt()); + // A move leaves the distances along the path as they are. + let moves = a0 == 1.0 && a1 == 0.0 && a2 == 0.0 && a3 == 1.0; + if !moves && scale.is_finite() && scale > 0.0 { + let scaled = |v: f64| Some(v * scale).filter(|s| s.is_finite()).unwrap_or(v); + let moved = paths.and_then(|(old, new)| self.moved_along(a, old, new, scale)); + let (spacing, start) = moved.unwrap_or((None, None)); + self.spacing = self.spacing.map(|g| spacing.unwrap_or_else(|| scaled(g))); + self.start = start.unwrap_or_else(|| scaled(self.start)); + } let after = tilt(self.rotate); - let [a0, a1, a2, a3, _, _] = a.as_coeffs(); for m in &mut self.members { m.frame = a * m.frame; let (sin, cos) = (m.angle - before).sin_cos(); @@ -117,6 +145,41 @@ impl ObjectsOnPath { } } } + /// The start and Space value on `new`, the path `old` after the transform `a` (regenerated, for + /// a live shape), that keep the first object and the last one where `a` takes them: each one's + /// point on `old`, mapped by `a`, measured along `new` near `scale` times its distance along + /// `old`. Either one is `None` when it cannot be followed that way: a start before or beyond the + /// ends of an open path, or a Space value of 0, one the path has no room for, or one with fewer + /// than two objects. `None` for a path without length or a start that is not finite. + fn moved_along(&self, a: Affine, old: &PathData, new: &PathData, scale: f64) -> Option<(Option, Option)> { + let (was, is) = (Spine::new(old)?, Spine::new(new)?); + let (len, closed) = path_length(old)?; + let (new_len, _) = path_length(new)?; + if !self.start.is_finite() { + return None; + } + let n = self.members.len(); + let (s0, gap) = layout(len, closed, n, self.spacing, self.start); + let follow = |s: f64| -> Option { + let (p, _) = was.at_length(s); + let t = is.nearest(a * p, Some(s * scale))?.1; + t.is_finite().then_some(if closed { wrap(t, new_len) } else { t.clamp(0.0, new_len) }) + }; + let t0 = follow(s0)?; + // On a closed path the start keeps the number of times it goes round, when that stays finite. + let start = + if closed { Some(t0 + ((self.start - s0) / len).round() * new_len).filter(|s| s.is_finite()) } else { (s0 == self.start).then_some(t0) }; + let spacing = self.spacing.filter(|g| n > 1 && g.is_finite() && *g > 0.0 && *g <= gap).and_then(|g| { + let t1 = follow(nth_length(len, closed, s0, gap, n - 1))?; + let steps = (n - 1) as f64; + // Round a closed path, the span nearest the scaled one. + let expected = steps * g * scale; + let span = if closed { wrap(t1 - t0 - expected + new_len / 2.0, new_len) - new_len / 2.0 + expected } else { t1 - t0 }; + let g = span / steps; + (g.is_finite() && g >= 0.0).then_some(g) + }); + Some((spacing, start)) + } /// The children given new ids (a copy of the group): the path and the objects by their new ids. pub fn rename(&mut self, ids: &HashMap) { let to = |id: NodeId| ids.get(&id).copied().unwrap_or(id); @@ -210,19 +273,75 @@ pub fn upright_frame(n: &Node) -> Affine { } } -/// Where `n` objects attach along `path` (its first subpath): the point and the direction there -/// (radians, as `atan2` gives in the document's axes). They are spread evenly by length from the -/// first anchor: on an open path the last one is at its end, on a closed path the gaps go all the way -/// around. `None` for a path without length. -pub fn stops(path: &PathData, n: usize) -> Option> { - let spine = Spine::new(path)?; - let len = spine.length(); - if !len.is_finite() || len <= 1e-9 { - return None; - } +/// The length of `path`'s first subpath and whether it is closed. `None` for a path without +/// length. +pub fn path_length(path: &PathData) -> Option<(f64, bool)> { + let len = Spine::new(path)?.length(); let closed = path.subpaths.first().is_some_and(|s| s.closed); - let gaps = if closed { n } else { n.saturating_sub(1) }.max(1); - Some((0..n).map(|i| spine.at_length(len * i as f64 / gaps as f64)).collect()) + (len.is_finite() && len > 1e-9).then_some((len, closed)) +} + +/// Where the first of `n` objects attaches along a path of length `len` (closed or not), and the +/// distance from each object's attachment point to the next one's, both along the path (points), +/// for Space `spacing` (`None`: Auto) from `start`: +/// - The first object is `start` from the first anchor: on an open path no further than its end, +/// and on a closed path less than once around. +/// - Auto spreads the objects evenly from there: on an open path the last one is at its end, and on +/// a closed path the gaps go all the way around. +/// - A Space value is kept to what the path has room for, so a shorter path draws the objects +/// closer while the value stays: on an open path the last object reaches its end at most, and on +/// a closed path the last one stops at least a gap short of the first. +/// +/// A Space value that is negative or not finite counts as Auto, and a `start` that is not finite as 0. +pub fn layout(len: f64, closed: bool, n: usize, spacing: Option, start: f64) -> (f64, f64) { + let start = match (start.is_finite(), closed) { + (false, _) => 0.0, + (true, true) => wrap(start, len), + (true, false) => start.clamp(0.0, len.max(0.0)), + }; + let room = if closed { + len / n.max(1) as f64 + } else if n > 1 { + (len - start) / (n - 1) as f64 + } else { + 0.0 + }; + let room = if room.is_finite() { room.max(0.0) } else { 0.0 }; + match spacing.filter(|g| g.is_finite() && *g >= 0.0) { + Some(g) => (start, g.min(room)), + None => (start, room), + } +} + +/// `s` taken round a closed path of length `len`: 0 or more, and less than `len`. +fn wrap(s: f64, len: f64) -> f64 { + let w = s.rem_euclid(len); + if w.is_finite() && w < len { w } else { 0.0 } +} + +/// Where `n` objects attach along `path` (its first subpath): the point and the direction there +/// (radians, as `atan2` gives in the document's axes). They go along it as [`layout`] spaces them +/// for Space `spacing` (`None`: Auto) from `start`. `None` for a path without length. +pub fn stops(path: &PathData, n: usize, spacing: Option, start: f64) -> Option> { + let spine = Spine::new(path)?; + let (len, closed) = path_length(path)?; + let (start, gap) = layout(len, closed, n, spacing, start); + Some((0..n).map(|i| spine.at_length(nth_length(len, closed, start, gap, i))).collect()) +} + +/// Where object `i` of `n` attaches along `path`, as [`stops`] places it, without the others. +pub fn stop(path: &PathData, n: usize, spacing: Option, start: f64, i: usize) -> Option<(Point, f64)> { + let spine = Spine::new(path)?; + let (len, closed) = path_length(path)?; + let (start, gap) = layout(len, closed, n, spacing, start); + (i < n).then(|| spine.at_length(nth_length(len, closed, start, gap, i))) +} + +/// The distance along a path of length `len` (closed or not) to object `i`, from `start` and +/// `gap` apart as [`layout`] gives them. +pub fn nth_length(len: f64, closed: bool, start: f64, gap: f64, i: usize) -> f64 { + let s = start + gap * i as f64; + if closed { wrap(s, len) } else { s.min(len) } } /// The tilt Rotate gives (radians, the document's axes, where y points down): `rotate` degrees @@ -277,7 +396,7 @@ pub fn place(g: &mut Node, symbols: &SymbolBoxes) -> bool { } } let mut moved = false; - if let Some(stops) = path.and_then(|p| stops(&p, rec.members.len())) { + if let Some(stops) = path.and_then(|p| stops(&p, rec.members.len(), rec.spacing, rec.start)) { let turn = tilt(rec.rotate); for (m, (p, dir)) in rec.members.iter_mut().zip(stops) { let Some(c) = at.get(&m.id).and_then(|i| children.get_mut(*i)) else { continue }; @@ -309,15 +428,192 @@ mod tests { #[test] fn stops_spread_from_the_first_anchor() { let open = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0)); - let s = stops(&open, 4).unwrap(); + let s = stops(&open, 4, None, 0.0).unwrap(); let xs: Vec = s.iter().map(|(p, _)| p.x.round()).collect(); assert_eq!(xs, [0.0, 100.0, 200.0, 300.0], "first at the start, last at the end of an open path"); let closed = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)); - let s = stops(&closed, 4).unwrap(); + let s = stops(&closed, 4, None, 0.0).unwrap(); let mut pts: Vec<(i64, i64)> = s.iter().map(|(p, _)| (p.x.round() as i64, p.y.round() as i64)).collect(); pts.sort(); assert_eq!(pts, [(0, 0), (0, 100), (100, 0), (100, 100)], "a closed path spreads them all the way round"); - assert!(stops(&shapes::line(Point::ZERO, Point::ZERO), 2).is_none()); + assert!(stops(&shapes::line(Point::ZERO, Point::ZERO), 2, None, 0.0).is_none()); + } + + /// The x of each stop on the `len` pt line from (0, 0) to the right, to 1/1000 pt. + fn xs_on_line(len: f64, n: usize, spacing: Option, start: f64) -> Vec { + let open = shapes::line(Point::new(0.0, 0.0), Point::new(len, 0.0)); + stops(&open, n, spacing, start).unwrap().iter().map(|(p, _)| (p.x * 1000.0).round() / 1000.0).collect() + } + + #[test] + fn space_sets_the_distance_between_the_stops() { + assert_eq!(xs_on_line(300.0, 4, Some(60.0), 0.0), [0.0, 60.0, 120.0, 180.0], "the first stays on the first anchor"); + assert_eq!(xs_on_line(300.0, 4, Some(60.0), 60.0), [60.0, 120.0, 180.0, 240.0], "centered on the path"); + assert_eq!(xs_on_line(300.0, 4, Some(0.0), 0.0), [0.0; 4], "no space stacks them"); + // More than the path has room for: the last one reaches the end. + assert_eq!(xs_on_line(300.0, 4, Some(500.0), 0.0), [0.0, 100.0, 200.0, 300.0]); + assert_eq!(xs_on_line(300.0, 4, Some(500.0), 150.0), [150.0, 200.0, 250.0, 300.0]); + // A shorter path draws them closer, while the Space value stays to come back with the length. + assert_eq!(xs_on_line(90.0, 4, Some(60.0), 0.0), [0.0, 30.0, 60.0, 90.0]); + assert_eq!(layout(90.0, false, 4, Some(60.0), 0.0), (0.0, 30.0)); + assert_eq!(layout(300.0, false, 4, Some(60.0), 0.0), (0.0, 60.0)); + // Values that are not usable count as Auto, and a start beyond the end stops at it. + assert_eq!(xs_on_line(300.0, 4, Some(f64::NAN), f64::INFINITY), [0.0, 100.0, 200.0, 300.0]); + assert_eq!(xs_on_line(300.0, 4, Some(-5.0), 0.0), [0.0, 100.0, 200.0, 300.0]); + assert_eq!(xs_on_line(300.0, 2, Some(10.0), 400.0), [300.0, 300.0]); + assert_eq!(xs_on_line(300.0, 1, Some(10.0), 20.0), [20.0], "one object has no space to set"); + assert_eq!(xs_on_line(300.0, 0, Some(10.0), 0.0), Vec::::new()); + // One stop on its own is where the stops put it. + let open = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0)); + let all = stops(&open, 4, Some(60.0), 60.0).unwrap(); + for (i, s) in all.iter().enumerate() { + assert_eq!(stop(&open, 4, Some(60.0), 60.0, i), Some(*s)); + } + assert_eq!(stop(&open, 4, Some(60.0), 60.0, 4), None); + } + + #[test] + fn space_on_a_closed_path_wraps_at_the_first_anchor_and_stops_short_of_the_first_object() { + // The square's outline is 400 pt round, from (0, 0) to the right and on clockwise. + let closed = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)); + let pts = |spacing, start| -> Vec<(i64, i64)> { + stops(&closed, 4, spacing, start).unwrap().iter().map(|(p, _)| (p.x.round() as i64, p.y.round() as i64)).collect() + }; + assert_eq!(pts(Some(50.0), 0.0), [(0, 0), (50, 0), (100, 0), (100, 50)]); + // Centered on the first anchor: the span goes back past it. + assert_eq!(pts(Some(50.0), -75.0), [(0, 75), (0, 25), (25, 0), (75, 0)]); + // At most a quarter of the way round each: no two objects meet at the first anchor. + assert_eq!(pts(Some(300.0), 0.0), [(0, 0), (100, 0), (100, 100), (0, 100)]); + assert_eq!(layout(400.0, true, 4, Some(300.0), 400.0), (0.0, 100.0)); + } + + #[test] + fn a_group_saved_without_space_reads_as_auto() { + let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)); + let rec = ObjectsOnPath::new(NodeId(1), &[&a], CENTER, 0.0); + let v = serde_json::to_value(&rec).unwrap(); + assert!(v.get("spacing").is_none() && v.get("start").is_none(), "Auto is not written: {v}"); + let back: ObjectsOnPath = serde_json::from_value(v).unwrap(); + assert_eq!((back.spacing, back.start), (None, 0.0)); + let set = ObjectsOnPath { spacing: Some(60.0), start: 12.5, ..rec }; + let back: ObjectsOnPath = serde_json::from_value(serde_json::to_value(&set).unwrap()).unwrap(); + assert_eq!(back, set); + } + + #[test] + fn transforming_the_group_scales_its_space() { + let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)); + let mut rec = ObjectsOnPath { spacing: Some(60.0), start: 12.5, ..ObjectsOnPath::new(NodeId(1), &[&a], CENTER, 30.0) }; + rec.transform(Affine::new([-2.0, 0.0, 0.0, 2.0, 5.0, 5.0]), None); + assert_eq!((rec.spacing, rec.start, rec.rotate), (Some(120.0), 25.0, -30.0)); + rec.transform(Affine::translate((40.0, 0.0)) * Affine::rotate(0.7), None); + assert!((rec.spacing.unwrap() - 120.0).abs() < 1e-9 && (rec.start - 25.0).abs() < 1e-9, "{rec:?}"); + // Twice as wide and no higher: a level path is stretched twice along its length. + let level = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0)); + let stretch = Affine::new([2.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + rec.transform(stretch, Some((&level, &level.transformed(stretch)))); + assert!((rec.spacing.unwrap() - 240.0).abs() < 1e-9 && (rec.start - 50.0).abs() < 1e-9, "{rec:?}"); + let mut auto = ObjectsOnPath::new(NodeId(1), &[&a], CENTER, 0.0); + auto.transform(Affine::scale(3.0), None); + assert_eq!((auto.spacing, auto.start), (None, 0.0), "Auto stays Auto"); + let mut huge = ObjectsOnPath { spacing: Some(1e308), start: -1e308, ..auto }; + huge.transform(Affine::scale(2.0), None); + assert_eq!((huge.spacing, huge.start), (Some(1e308), -1e308), "kept finite"); + } + + /// Stretching a path that turns a corner keeps the first and last objects where the stretch + /// takes them, not where the whole path's change in length would. + #[test] + fn a_stretch_keeps_the_first_and_last_objects_where_it_takes_them() { + // 100 pt to the right, then 100 pt down; twice as wide, the first part is 200 pt long. + let points = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)]; + let corner = PathData::single(vectorcraft_geom::SubPath::polyline(&points, false)); + let stretch = Affine::scale_non_uniform(2.0, 1.0); + let wide = corner.transformed(stretch); + let (a, b) = (rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)), rect(3, Rect::new(20.0, 0.0, 30.0, 10.0))); + let mut rec = ObjectsOnPath { spacing: Some(40.0), start: 50.0, ..ObjectsOnPath::new(NodeId(1), &[&a, &b], CENTER, 0.0) }; + rec.transform(stretch, Some((&corner, &wide))); + assert!((rec.start - 100.0).abs() < 1e-9 && (rec.spacing.unwrap() - 80.0).abs() < 1e-9, "{rec:?}"); + // Round a closed path, from before its first anchor: the start goes round as many times. + let square = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)); + let wide = square.transformed(stretch); + let mut rec = ObjectsOnPath { spacing: Some(50.0), start: -75.0, ..ObjectsOnPath::new(NodeId(1), &[&a, &b], CENTER, 0.0) }; + let before = stops(&square, 2, rec.spacing, rec.start).unwrap(); + rec.transform(stretch, Some((&square, &wide))); + let after = stops(&wide, 2, rec.spacing, rec.start).unwrap(); + for ((p, _), (q, _)) in before.iter().zip(&after) { + assert!((stretch * *p).distance(*q) < 1e-9, "{p:?} went to {q:?}: {rec:?}"); + } + assert!(rec.start < 0.0, "{rec:?}"); + } + + /// A start round a closed path so many times that scaling it would not be finite stays as it is. + #[test] + fn a_closed_path_start_too_large_to_scale_stays_finite() { + let square = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)); + let grow = Affine::scale(2.0); + let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)); + let mut rec = ObjectsOnPath { start: 1e308, ..ObjectsOnPath::new(NodeId(1), &[&a], CENTER, 0.0) }; + rec.transform(grow, Some((&square, &square.transformed(grow)))); + assert_eq!(rec.start, 1e308, "{rec:?}"); + } + + /// Stretching the group across its level path only keeps the objects where the stretch takes + /// them: twice as far apart. + #[test] + fn a_group_stretched_along_its_path_keeps_the_objects_in_proportion() { + let line = Node::path(NodeId(1), shapes::line(Point::new(0.0, 100.0), Point::new(300.0, 100.0)), Default::default()); + let (a, b) = (rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)), rect(3, Rect::new(20.0, 0.0, 30.0, 10.0))); + let mut g = Node::new(NodeId(4), NodeKind::Group { children: vec![Arc::new(line), Arc::new(a.clone()), Arc::new(b.clone())], clip: false }); + let rec = ObjectsOnPath { spacing: Some(20.0), start: 10.0, ..ObjectsOnPath::new(NodeId(1), &[&a, &b], CENTER, 0.0) }; + g.extras_mut().objects_on_path = Some(Box::new(rec)); + let empty = crate::Document::new(1.0, 1.0); + let symbols = empty.symbol_boxes(); + place(&mut g, &symbols); + g.transform(Affine::new([2.0, 0.0, 0.0, 1.0, 0.0, 0.0]), false); + let rec = g.extras().objects_on_path.as_deref().cloned().unwrap(); + assert_eq!((rec.spacing, rec.start), (Some(40.0), 20.0)); + place(&mut g, &symbols); + let x = |id| g.children().and_then(|c| c.iter().find(|c| c.id == id).cloned()).unwrap().geometric_bounds().unwrap().center().x; + assert!((x(NodeId(2)) - 20.0).abs() < 1e-9 && (x(NodeId(3)) - 60.0).abs() < 1e-9, "{} {}", x(NodeId(2)), x(NodeId(3))); + } + + #[test] + fn length_near_measures_along_the_path_and_past_its_ends() { + // An L: 400 pt to the right, then 300 pt up. + let points = [Point::new(0.0, 300.0), Point::new(400.0, 300.0), Point::new(400.0, 0.0)]; + let l = PathData::single(vectorcraft_geom::SubPath::polyline(&points, false)); + let spine = Spine::new(&l).unwrap(); + assert!((spine.length_near(Point::new(300.0, 320.0), None) - 300.0).abs() < 1e-9); + assert!((spine.length_near(Point::new(420.0, 100.0), None) - 600.0).abs() < 1e-9); + assert!((spine.length_near(Point::new(400.0, -26.0), None) - 726.0).abs() < 1e-9, "beyond the end"); + assert!((spine.length_near(Point::new(-50.0, 300.0), None) + 50.0).abs() < 1e-9, "before the start"); + // A path that turns back near its start: beyond its end, the pointer is nearer the first + // segment than the last one, but on the line of the last one. + let hook = [Point::new(0.0, 0.0), Point::new(200.0, 0.0), Point::new(200.0, 10.0), Point::new(100.0, 10.0)]; + let hook = Spine::new(&PathData::single(vectorcraft_geom::SubPath::polyline(&hook, false))).unwrap(); + assert!((hook.length_near(Point::new(80.0, 10.0), None) - 330.0).abs() < 1e-9); + assert!((hook.length_near(Point::new(50.0, 10.0), None) - 360.0).abs() < 1e-9); + let closed = Spine::new(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))).unwrap(); + assert!((closed.length_near(Point::new(-30.0, -30.0), None)).abs() < 1e-9, "a closed path has no ends"); + } + + #[test] + fn length_near_a_point_followed_stays_on_that_part_of_the_path() { + // A narrow U, open on the left: its ends are 20 pt apart, 620 pt from each other along it. + let u = [Point::new(0.0, 0.0), Point::new(300.0, 0.0), Point::new(300.0, 20.0), Point::new(0.0, 20.0)]; + let u = Spine::new(&PathData::single(vectorcraft_geom::SubPath::polyline(&u, false))).unwrap(); + assert!((u.length_near(Point::new(-26.0, 20.0), None) - 646.0).abs() < 1e-9, "beyond the end"); + // Nearer the first segment's line, the pointer stays beyond the end it was following. + assert!((u.length_near(Point::new(-26.0, 8.0), None) + 26.0).abs() < 1e-9); + assert!((u.length_near(Point::new(-26.0, 8.0), Some(646.0)) - 646.0).abs() < 1e-9); + // Following the path round, it goes on from where it was. + assert!((u.length_near(Point::new(305.0, 10.0), Some(312.0)) - 310.0).abs() < 1e-9); + // Round a closed path's first anchor: from 10 pt short of it to 20 pt past it. + let square = Spine::new(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))).unwrap(); + let (at, _) = square.at_length(390.0); + assert!((square.length_near(at, None) - 390.0).abs() < 1e-9); + assert!((square.length_near(Point::new(20.0, -5.0), Some(390.0)) - 20.0).abs() < 1e-9); } #[test] diff --git a/crates/doc/src/orient.rs b/crates/doc/src/orient.rs index 114a15290..e524d6ca3 100644 --- a/crates/doc/src/orient.rs +++ b/crates/doc/src/orient.rs @@ -125,9 +125,19 @@ impl Document { if angle == 0.0 { return self.bounds_of(ids, visual).map(OrientedBox::aligned); } + self.oriented_bounds_with(ids, visual, &self.symbol_boxes()) + } + + /// [`oriented_bounds`](Document::oriented_bounds) measuring symbol instances from `symbols`, + /// a cache shared across calls: measuring many instances one by one otherwise measures their + /// art again for each. + pub fn oriented_bounds_with(&self, ids: &[NodeId], visual: bool, symbols: &crate::SymbolBoxes) -> Option { + let angle = self.bbox_angle(ids); + if angle == 0.0 { + return self.bounds_of_with(ids, visual, symbols).map(OrientedBox::aligned); + } let to_local = OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse(); - let symbols = self.symbol_boxes(); - let rect = ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| union_opt(acc, n.bounds_in(&symbols, to_local, visual)))?; + let rect = ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| union_opt(acc, n.bounds_in(symbols, to_local, visual)))?; Some(OrientedBox { angle, rect }) } } diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index a0b19b844..2a98df62c 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -522,7 +522,28 @@ pub struct RepeatSpec { /// many as one 500 × 500 grid, the budget [`PatternDef::offsets_covering`] also keeps. const MAX_REPEAT_INSTANCES: usize = 250_000; -/// Counts the instances Repeats make in all, following symbol instances into their symbol's art +/// Most nodes and anchors a Repeat copies in all, those of the Repeats nested in its source and +/// of the opacity masks in it included: each instance copies the whole source, so a large source +/// keeps fewer instances than [`MAX_REPEAT_INSTANCES`] allows (a 500 × 500 grid of a shape of up +/// to 39 anchors keeps them all). +const MAX_REPEAT_COPIES: usize = 10_000_000; + +/// What the Repeats in some art make in all ([`NestedCount::nodes`]). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct Cost { + /// Instances the Repeats make (0 when there are none). + instances: usize, + /// Nodes and path anchors drawn, each Repeat's source counted once per instance it keeps. + copies: usize, +} + +impl Cost { + fn plus(self, o: Cost) -> Cost { + Cost { instances: self.instances.saturating_add(o.instances), copies: self.copies.saturating_add(o.copies) } + } +} + +/// Counts the instances Repeats make, and the nodes they copy, in all, following symbol instances into their symbol's art /// in `doc`: each symbol's art is counted once, and a missing symbol, or an instance of a symbol /// inside its own art, counts as none. /// @@ -549,21 +570,27 @@ impl<'a> NestedCount<'a> { } let Some(sym) = self.doc.and_then(|d| d.symbols.iter().find(|s| s.name == name)) else { return 0 }; self.symbols.borrow_mut().insert(name.to_owned(), None); - let n = self.nodes(std::slice::from_ref(&sym.art)); + let n = self.nodes(std::slice::from_ref(&sym.art)).instances; self.symbols.borrow_mut().insert(name.to_owned(), Some(n)); n } - /// Instances the Repeats in `nodes` make in all (0 when there are none). - fn nodes(&self, nodes: &[Arc]) -> usize { - let mut sum = 0usize; + /// What the Repeats in `nodes` make in all, in the art of their enabled opacity masks too + /// (drawn again with each copy of the object they mask). + fn nodes(&self, nodes: &[Arc]) -> Cost { + let mut sum = Cost::default(); for n in nodes { - sum = sum.saturating_add(match (&n.kind, n.children()) { - (NodeKind::Repeat(r), _) => r.instances_in_all(self), - (NodeKind::SymbolInstance { symbol, .. }, _) => self.symbol(symbol), - (_, Some(c)) => self.nodes(c), - _ => 0, - }); + let own = match (&n.kind, n.children()) { + (NodeKind::Repeat(r), _) => r.in_all(self), + (NodeKind::SymbolInstance { symbol, .. }, _) => Cost { instances: self.symbol(symbol), copies: 1 }, + (NodeKind::Path { path, .. }, _) => Cost { instances: 0, copies: path.anchor_count().saturating_add(1) }, + (_, Some(c)) => self.nodes(c).plus(Cost { instances: 0, copies: 1 }), + _ => Cost { instances: 0, copies: 1 }, + }; + sum = sum.plus(own); + if let Some(m) = n.extras().mask.as_ref().filter(|m| !m.disabled) { + sum = sum.plus(self.nodes(std::slice::from_ref(&m.art))); + } } sum } @@ -643,21 +670,22 @@ impl RepeatSpec { v.truncate(self.kept(NestedCount::new(doc).nodes(&self.source))); v } - /// How many of its instances this Repeat keeps when each makes `nested` instances of the - /// Repeats in its source: as many as fit in [`MAX_REPEAT_INSTANCES`], at least one. - fn kept(&self, nested: usize) -> usize { - self.kind.count().min(MAX_REPEAT_INSTANCES / nested.max(1)).max(1) + /// How many of its instances this Repeat keeps when its source costs `nested`: as many as fit + /// in [`MAX_REPEAT_INSTANCES`] and [`MAX_REPEAT_COPIES`], at least one. + fn kept(&self, nested: Cost) -> usize { + self.kind.count().min(MAX_REPEAT_INSTANCES / nested.instances.max(1)).min(MAX_REPEAT_COPIES / nested.copies.max(1)).max(1) } /// How many instances this Repeat draws ([`Self::transforms`] for `doc`): as many as its /// options make, fewer when the Repeats in its source would take it past - /// [`MAX_REPEAT_INSTANCES`]. + /// [`MAX_REPEAT_INSTANCES`], or the copies of its source past [`MAX_REPEAT_COPIES`]. pub fn instances(&self, doc: Option<&Document>) -> usize { self.kept(NestedCount::new(doc).nodes(&self.source)) } - /// Instances made in all, those of the Repeats nested in the source included. - fn instances_in_all(&self, count: &NestedCount) -> usize { + /// Instances made and nodes copied in all, those of the Repeats nested in the source included. + fn in_all(&self, count: &NestedCount) -> Cost { let nested = count.nodes(&self.source); - self.kept(nested).saturating_mul(nested.max(1)) + let kept = self.kept(nested); + Cost { instances: kept.saturating_mul(nested.instances.max(1)), copies: kept.saturating_mul(nested.copies).saturating_add(1) } } /// Evaluated instances: one group of the (transformed) source per instance (ids 0), as many /// as [`Self::transforms`] keeps for `doc`. @@ -1005,6 +1033,42 @@ mod tests { assert_eq!(made(&small), 18); } + #[test] + fn repeats_in_opacity_masks_share_the_instance_budget() { + let grid = |source: Arc| { + let mut g = RepeatSpec::grid(vec![source], 0.0, 0.0); + if let RepeatKind::Grid { rows, cols, .. } = &mut g.kind { + *rows = 500; + *cols = 500; + } + g + }; + // A shape masked by a 500 × 500 grid draws the grid again for each copy. + let inner = Node::new(NodeId(3), NodeKind::Repeat(grid(sq(0.0, 0.0, 1.0)))); + let mut masked = (*sq(0.0, 0.0, 1.0)).clone(); + masked.extras_mut().mask = Some(Box::new(crate::node::OpacityMask::new(inner, true))); + let outer = grid(Arc::new(masked.clone())); + assert_eq!(outer.transforms(None).len(), 1); + // A disabled mask is not drawn, so it costs nothing. + if let Some(m) = &mut masked.extras_mut().mask { + m.disabled = true; + } + assert_eq!(grid(Arc::new(masked)).transforms(None).len(), 250_000); + } + + #[test] + fn a_large_source_keeps_fewer_copies() { + let mut g = RepeatSpec::grid((0..1000).map(|i| sq(i as f64, 0.0, 1.0)).collect(), 0.0, 0.0); + if let RepeatKind::Grid { rows, cols, .. } = &mut g.kind { + *rows = 500; + *cols = 500; + } + // Each copy is 1000 paths of 4 anchors: far fewer than 250 000 of them fit. + let kept = g.transforms(None).len(); + assert!(kept.saturating_mul(5000) <= MAX_REPEAT_COPIES, "{kept} copies kept"); + assert!(kept > 1); + } + #[test] fn serde_roundtrip() { let mut def = PatternDef::new("Dots", vec![sq(0.0, 0.0, 10.0)]); diff --git a/crates/doc/src/perspective.rs b/crates/doc/src/perspective.rs index 6ec18bcfa..ab347529e 100644 --- a/crates/doc/src/perspective.rs +++ b/crates/doc/src/perspective.rs @@ -63,7 +63,7 @@ impl Node { pub(crate) fn transform_projection(&mut self, a: vectorcraft_geom::Affine) { if let Some(p) = self.extras_if_any().and_then(|e| e.perspective.as_deref_mut()) && let Some(h) = p.homography() - && a.determinant().abs() >= 1e-15 + && Homography::affine_is_invertible(a) { p.projection = h.conjugated(a).map(|h| h.to_array()); } diff --git a/crates/doc/src/placed_document.rs b/crates/doc/src/placed_document.rs index 310d7b784..33eccb178 100644 --- a/crates/doc/src/placed_document.rs +++ b/crates/doc/src/placed_document.rs @@ -559,6 +559,9 @@ impl Document { reads += usize::from(read_full && depth > 0); let full = if read_full { full_bytes(&p, &blob) } else { None }; if full.and_then(|b| read(&p, &b)).is_none() { + // A full read cached for another caller (one allowed to read the file) must not + // stand in for the one refused or not tried here: the preview shows instead. + cache().entries.remove(&(p.key.clone(), false)); let preview = if blob.is_proxy() { Some(blob.bytes.clone()) } else { blob.proxy.clone() }; if let Some(bytes) = preview { read_preview(&p, &bytes); diff --git a/crates/doc/src/range.rs b/crates/doc/src/range.rs index 87db16f20..7162709bc 100644 --- a/crates/doc/src/range.rs +++ b/crates/doc/src/range.rs @@ -17,7 +17,9 @@ pub fn parse_range(s: &str, count: usize) -> Result, String> { .ok_or_else(|| format!("range `{s}`: `{t}` is not a number from 1 to {count}")) }; let mut out = vec![]; - let mut seen = std::collections::HashSet::new(); + // Past each run of indices picked so far, the index after it: an interval skips the runs it + // overlaps instead of walking them again, so repeating `1-` costs no more than writing it once. + let mut next = std::collections::HashMap::new(); for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) { let (a, b) = match part.replace('\u{2013}', "-").split_once('-') { Some((a, b)) if a.trim().is_empty() && b.trim().is_empty() => return Err(format!("range `{s}`: `{part}` names no number")), @@ -30,10 +32,11 @@ pub fn parse_range(s: &str, count: usize) -> Result, String> { if a > b { return Err(format!("range `{s}`: `{part}` runs backwards")); } - for i in a - 1..b { - if seen.insert(i) { - out.push(i); - } + let mut i = unpicked(&mut next, a - 1); + while i < b { + out.push(i); + next.insert(i, i + 1); + i = unpicked(&mut next, i + 1); } } if out.is_empty() { @@ -42,6 +45,23 @@ pub fn parse_range(s: &str, count: usize) -> Result, String> { Ok(out) } +/// The first index from `i` on not picked yet, `next` linking each picked index onward (the links +/// followed are then pointed straight at it). +fn unpicked(next: &mut std::collections::HashMap, i: usize) -> usize { + let mut end = i; + while let Some(&j) = next.get(&end) { + end = j; + } + let mut at = i; + while at != end { + match next.insert(at, end) { + Some(j) => at = j, + None => break, + } + } + end +} + #[cfg(test)] mod tests { use super::parse_range; @@ -53,4 +73,20 @@ mod tests { assert!(parse_range("-+2", 5).is_err()); assert_eq!(parse_range("1-3, 5", 5), Ok(vec![0, 1, 2, 4])); } + + #[test] + fn repeats_and_overlaps_are_dropped_in_order() { + assert_eq!(parse_range("3-5, 1-4, 2, 6-, -1", 7), Ok(vec![2, 3, 4, 0, 1, 5, 6])); + assert_eq!(parse_range("2,2,1-2", 4), Ok(vec![1, 0])); + assert_eq!(parse_range("4, 2, 1-", 5), Ok(vec![3, 1, 0, 2, 4])); + } + + #[test] + fn a_range_repeated_many_times_is_read_once() { + // 100 000 pages named 50 000 times: walking the pages for each would take billions of steps. + let s = "1-,".repeat(50_000); + let pages = parse_range(&s, 100_000).unwrap(); + assert_eq!(pages.len(), 100_000); + assert!(pages.iter().enumerate().all(|(i, p)| i == *p)); + } } diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs index 12965821b..0c4ef497f 100644 --- a/crates/doc/src/reach.rs +++ b/crates/doc/src/reach.rs @@ -2,7 +2,7 @@ //! visual bounds and the clicks of a stroke take in its alignment, its width profile, miter spikes //! up to the miter limit, projecting caps and arrowheads. -use vectorcraft_geom::bez::{CORNER_COS, kink, segments, subpaths, tangent, unit}; +use vectorcraft_geom::bez::{CORNER_COS, has_length, kink, segments, subpaths, tangent, unit}; use vectorcraft_geom::hit::fill_contains; use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest}; use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathSeg, Point, Rect, Shape, Vec2}; @@ -16,12 +16,7 @@ const ARCLEN_ACCURACY: f64 = 1e-3; /// The segments of a subpath that have length (zero-length ones, all of whose points coincide, /// have no direction). fn drawn(els: &[vectorcraft_geom::PathEl]) -> Vec { - let moves = |s: &PathSeg| match s { - PathSeg::Line(l) => l.p1 != l.p0, - PathSeg::Quad(q) => q.p1 != q.p0 || q.p2 != q.p0, - PathSeg::Cubic(c) => c.p1 != c.p0 || c.p2 != c.p0 || c.p3 != c.p0, - }; - segments(els).into_iter().filter(moves).collect() + segments(els).into_iter().filter(has_length).collect() } /// The open subpath ends of `segs`: (point, unit direction out of the path) at its start and end. @@ -166,6 +161,47 @@ impl StrokeLayer { out.union(bounds.inflate(side_max, side_max)) } + /// The tip of the miter spike this stroke paints where segment `b` follows segment `a` in a + /// subpath of its path (`closed`: a closed one), both with length ([`has_length`]), or `None` + /// when the join is not a miter, the path does not turn there or the miter limit bevels it. + pub fn miter_tip(&self, closed: bool, a: &PathSeg, b: &PathSeg) -> Option { + let side = self.side_reach(closed); + if self.join != LineJoin::Miter || side <= 0.0 { + return None; + } + miter(a.end(), tangent(a, 1.0), tangent(b, 0.0), side, self.miter_limit).map(|[_, tip, _]| tip) + } + + /// [`Self::bounds_on`] with the miter spikes' tips taken from `tips`, the box of the tips of + /// every joint of `bp` ([`Self::miter_tip`]) or `None` when there are none, instead of measured + /// along `bp`. It measures the projecting caps and arrowheads as [`Self::bounds_on`] does. + pub fn bounds_with_tips(&self, bp: &BezPath, bounds: Rect, tips: Option) -> Rect { + let els = bp.elements(); + let mut out = tips.map_or(bounds, |t| bounds.union(t)); + let mut side_max: f64 = 0.0; + // Dashes put a cap on every dash: projecting ones reach a corner's diagonal anywhere. + let capped_dashes = self.cap == LineCap::Square && self.dash.as_ref().is_some_and(Dash::is_dashed); + for (r, closed) in subpaths(bp) { + let side = self.side_reach(closed); + side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side }); + if side <= 0.0 { + continue; + } + if self.cap == LineCap::Square && !closed { + let segs = drawn(&els[r]); + for (at, dir) in ends(&segs).into_iter().flatten() { + let n = Vec2::new(-dir.y, dir.x) * side; + out = out.union_pt(at + dir * side + n).union_pt(at + dir * side - n); + } + } + } + for (at, _, end) in head_ends(self, bp) { + let r = self.head_reach(end); + out = out.union(Rect::from_center_size(at, (2.0 * r, 2.0 * r))); + } + out.union(bounds.inflate(side_max, side_max)) + } + /// Does this stroke along `bp` (filled with `rule`) paint within `tol` of `p`? Inside and /// outside strokes of closed paths count on their own side only (and within `tol` of the /// path); a width profile counts at its width where `p` is nearest. @@ -271,13 +307,21 @@ impl Appearance { pub fn stroked_bounds(&self, bounds: Rect, bp: impl FnOnce() -> BezPath) -> Rect { let mut path = Some(bp); let mut shape = None; + self.stroked_bounds_by(bounds, |s| { + let bp: &BezPath = shape.get_or_insert_with(|| path.take().map(|f| f()).unwrap_or_default()); + s.bounds_on(bp, bounds) + }) + } + + /// [`Self::stroked_bounds`] with the box of each visible stroke that needs the path's shape + /// (miter spikes, projecting caps, arrowheads or an alignment) given by `shaped`. + pub fn stroked_bounds_by(&self, bounds: Rect, mut shaped: impl FnMut(&StrokeLayer) -> Rect) -> Rect { self.painted_strokes().fold(bounds, |acc, s| { if !s.needs_shape() { let o = s.side_reach(false); return acc.union(bounds.inflate(o, o)); } - let bp: &BezPath = shape.get_or_insert_with(|| path.take().map(|f| f()).unwrap_or_default()); - acc.union(s.bounds_on(bp, bounds)) + acc.union(shaped(s)) }) } diff --git a/crates/doc/src/skip.rs b/crates/doc/src/skip.rs index 15f294c00..b26a1a7ac 100644 --- a/crates/doc/src/skip.rs +++ b/crates/doc/src/skip.rs @@ -13,3 +13,8 @@ pub fn is_empty_vec(v: &[T]) -> bool { pub fn is_empty_map(v: &serde_json::Map) -> bool { v.is_empty() } +/// Read a field's value and drop it, giving `None`: for state that is never taken from a file. +pub fn read_as_none<'de, D: serde::Deserializer<'de>, T>(d: D) -> Result, D::Error> { + ::deserialize(d)?; + Ok(None) +} diff --git a/crates/doc/src/style_libs.rs b/crates/doc/src/style_libs.rs index 30aae49e1..6eda3acef 100644 --- a/crates/doc/src/style_libs.rs +++ b/crates/doc/src/style_libs.rs @@ -98,6 +98,10 @@ impl GraphicStyle { /// The extension (and `format` header) of graphic style library files. pub const STYLES_EXT: &str = "vcstyles"; +/// The version of the `.vcstyles` files [`write`] writes. Version 2: a positive Twist angle turns +/// clockwise ([`crate::twist`]). +const STYLES_VERSION: u32 = crate::twist::TWIST_CLOCKWISE_SINCE; + /// The file: a header around the library. #[derive(Serialize, Deserialize)] struct NativeFile { @@ -109,17 +113,21 @@ struct NativeFile { /// `lib` as a `.vcstyles` file. pub fn write(lib: &StyleLibrary) -> String { - let file = NativeFile { format: STYLES_EXT.into(), version: 1, library: lib.clone() }; + let file = NativeFile { format: STYLES_EXT.into(), version: STYLES_VERSION, library: lib.clone() }; serde_json::to_string_pretty(&file).unwrap_or_default() } -/// Read a `.vcstyles` file; an unnamed library is called `name`. +/// Read a `.vcstyles` file; an unnamed library is called `name`. A version 1 file was written by +/// an app that turned Twist counterclockwise: its Twist angles are negated. pub fn read(text: &str, name: &str) -> Result { let f: NativeFile = serde_json::from_str(text.trim_start_matches('\u{feff}')).map_err(|e| format!("not a graphic style library: {e}"))?; if f.format != STYLES_EXT { return Err(format!("not a graphic style library (format `{}`)", f.format)); } let mut lib = f.library; + if f.version < STYLES_VERSION { + crate::twist::negate_library(&mut lib); + } if lib.name.trim().is_empty() { lib.name = name.into(); } diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 95978bd8d..528e33e3e 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -110,8 +110,9 @@ impl Document { /// Visit every color that can link to a swatch (solid paints and gradient stops of fills, /// strokes and text runs) in the art (opacity masks included), symbol definitions, graph - /// designs, pattern tiles and the tile that pattern editing's Cancel puts back, graphic styles - /// and the swatches themselves (tint swatches, gradient swatches' stops). + /// designs, pattern tiles and the tile that pattern editing's Cancel puts back, graphic styles, + /// character styles' fill and stroke and the swatches themselves (tint swatches, gradient + /// swatches' stops). /// `f(colour, link, tint)` may change them and returns true when it did /// ([`Paint::map_links`]). Only nodes holding a changed paint (and the paths to them) are /// copied. Returns the number of changed paints. @@ -134,6 +135,11 @@ impl Document { for w in self.swatches_iter_mut() { n += usize::from(w.paint.map_links(f)); } + for def in &mut self.char_styles { + for key in ["fill", "stroke"] { + n += usize::from(map_style_paint(&mut def.attrs, key, f)); + } + } n } @@ -198,6 +204,23 @@ pub fn color_name(c: Color) -> String { /// true when it changed them. pub type LinkFn<'a> = dyn FnMut(&mut Color, &mut Option, &mut f32) -> bool + 'a; +/// Apply `f` to the paint a character style's attribute `key` holds, as [`Paint::map_links`] +/// does, and store the result in the attribute. True when it changed. +fn map_style_paint(attrs: &mut serde_json::Map, key: &str, f: &mut LinkFn) -> bool { + let Some(value) = attrs.get_mut(key) else { return false }; + let Ok(mut paint) = serde_json::from_value::(value.clone()) else { return false }; + if !paint.map_links(f) { + return false; + } + match serde_json::to_value(&paint) { + Ok(changed) => { + *value = changed; + true + } + Err(_) => false, + } +} + /// Every colour in the subtree of `n` with the swatch it links to: solid fills, strokes, text runs /// and gradient stops (with their link), then mesh points (unlinked), in paint order. pub fn node_colors(n: &Node, f: &mut dyn FnMut(&Color, Option<&str>)) { diff --git a/crates/doc/src/symbol_nine_slice.rs b/crates/doc/src/symbol_nine_slice.rs index a37b92eef..acc682872 100644 --- a/crates/doc/src/symbol_nine_slice.rs +++ b/crates/doc/src/symbol_nine_slice.rs @@ -71,6 +71,79 @@ impl SymbolNineSlice { } } +/// One of the four 9-slice guides. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NineSliceGuide { + Left, + Top, + Right, + Bottom, +} + +impl NineSliceGuide { + pub const ALL: [Self; 4] = [Self::Left, Self::Top, Self::Right, Self::Bottom]; +} + +/// The narrowest center band a guide dragged toward the opposite guide leaves, as a fraction of +/// the art's width or height (VectorCraft's own rule). +pub const MIN_CENTER_BAND: f64 = 0.001; + +/// The 9-slice guides of the symbol edited in symbol editing mode, over its art on the editing +/// layer ([`Document::edited_nine_slice`]). +#[derive(Clone, Debug, PartialEq)] +pub struct EditedNineSlice { + /// The symbol's name. + pub symbol: String, + pub guides: SymbolNineSlice, + /// The art's geometric box at its natural size. + art: Rect, + /// The art at its natural size → the document. + to_doc: Affine, +} + +impl EditedNineSlice { + /// Guide `g` as a line across the art's box, from one edge to the other, in the document. + pub fn line(&self, g: NineSliceGuide) -> (Point, Point) { + let (b, s) = (self.art, self.guides); + let (a, c) = match g { + NineSliceGuide::Left | NineSliceGuide::Right => { + let x = if g == NineSliceGuide::Left { b.x0 + s.left * b.width() } else { b.x1 - s.right * b.width() }; + (Point::new(x, b.y0), Point::new(x, b.y1)) + } + NineSliceGuide::Top | NineSliceGuide::Bottom => { + let y = if g == NineSliceGuide::Top { b.y0 + s.top * b.height() } else { b.y1 - s.bottom * b.height() }; + (Point::new(b.x0, y), Point::new(b.x1, y)) + } + }; + (self.to_doc * a, self.to_doc * c) + } + + /// The four guides' lines: left, top, right, bottom. + pub fn lines(&self) -> [(NineSliceGuide, Point, Point); 4] { + NineSliceGuide::ALL.map(|g| { + let (a, b) = self.line(g); + (g, a, b) + }) + } + + /// The guides with guide `g` moved across its axis to document point `p`. The guide stays + /// within the art's box, and [`MIN_CENTER_BAND`] short of the opposite guide, or where it is + /// when the guides are already closer than that. A point that is not finite leaves the guides + /// as they are. + pub fn moved(&self, g: NineSliceGuide, p: Point) -> SymbolNineSlice { + let (b, mut out) = (self.art, self.guides); + let q = self.to_doc.inverse() * p; + let fit = |v: f64, other: f64, now: f64| if v.is_finite() { v.clamp(0.0, (1.0 - other - MIN_CENTER_BAND).max(now)) } else { now }; + match g { + NineSliceGuide::Left => out.left = fit((q.x - b.x0) / b.width(), out.right, out.left), + NineSliceGuide::Right => out.right = fit((b.x1 - q.x) / b.width(), out.left, out.right), + NineSliceGuide::Top => out.top = fit((q.y - b.y0) / b.height(), out.bottom, out.top), + NineSliceGuide::Bottom => out.bottom = fit((b.y1 - q.y) / b.height(), out.top, out.bottom), + } + out + } +} + /// The most objects [`NineSliceMap::place`] makes for each object of the art it places: an /// object drawn in nine cells becomes a group of nine clip groups, each a clip path and a copy. pub const MAX_SLICE_GROWTH: u64 = 28; @@ -270,6 +343,20 @@ impl NineSliceMap { self.cell_xf(self.x.cell(q.x), self.y.cell(q.y)) } + /// A box that holds everything [`Self::place`] draws of art whose geometric box in symbol space + /// is `art`, before strokes and effects: `art` placed by each of the nine cells' maps. An object + /// that crosses a guide and draws symbol art is placed by the map of the cell its center is in, + /// so it can draw outside the box the corner cells place `art` in. + pub fn placement_bounds(&self, art: Rect) -> Rect { + let mut b = self.cell_xf(0, 0).transform_rect_bbox(art); + for j in 0..3 { + for i in 0..3 { + b = b.union(self.cell_xf(i, j).transform_rect_bbox(art)); + } + } + b + } + /// The guides in symbol space: the two x values and the two y values. fn cuts(&self) -> ([f64; 2], [f64; 2]) { let inv = self.natural.inverse(); @@ -364,6 +451,11 @@ impl NineSliceMap { self.most_at(art, &boxes, 0) } + /// [`Self::most_placed`] for [`Self::place_unclipped`]: the most objects it makes of `art`. + pub fn most_placed_unclipped(&self, art: &Node, doc: &Document) -> u64 { + Self { clips: false, ..*self }.most_placed(art, doc) + } + /// [`Self::most_placed`] for `n` at nesting depth `depth`, following [`Self::place_at`]. fn most_at(&self, n: &Node, boxes: &SymbolBoxes, depth: u32) -> u64 { let whole = n.count_with_masks() as u64; @@ -626,6 +718,12 @@ impl Document { NineSliceMap::new(guides, art, self.symbol_natural_xf(name), xf) } + /// [`Self::symbol_slice_map`] with the guides cutting `art`, the symbol's art box already + /// measured. + pub(crate) fn symbol_slice_map_of(&self, name: &str, art: Rect, xf: Affine) -> Option { + NineSliceMap::new(self.symbol_nine_slice(name)?, art, self.symbol_natural_xf(name), xf) + } + /// Art `art` of symbol `name` (stained as the caller draws it) placed by an instance's /// transform `xf`, with strokes at their own weight: through the symbol's 9-slice guides when /// it has them ([`NineSliceMap::place`]), otherwise by `xf`. `None` when placing it through @@ -638,14 +736,30 @@ impl Document { } } + /// The 9-slice guides of the symbol being edited in symbol editing mode, over its art on the + /// editing layer: `None` outside the mode, when the symbol has no guides, or when its art has + /// no width or height. + pub fn edited_nine_slice(&self) -> Option { + let se = self.symbol_edit.as_ref()?; + let guides = self.symbol_nine_slice(&se.symbol)?; + let sym = self.symbols.iter().find(|s| s.name == se.symbol)?; + let art = self.symbol_boxes().quick().art_bounds(&se.symbol).or_else(|| sym.art.geometric_bounds())?; + let natural = self.symbol_natural_xf(&se.symbol); + let b = natural.transform_rect_bbox(art); + if !finite(b) || b.width() <= 1e-9 || b.height() <= 1e-9 || natural.determinant().abs() < 1e-18 { + return None; + } + let to_doc = se.xf * natural.inverse(); + let det = to_doc.determinant(); + let usable = to_doc.as_coeffs().into_iter().all(f64::is_finite) && det.is_finite() && det.abs() > 1e-18; + usable.then(|| EditedNineSlice { symbol: se.symbol.clone(), guides, art: b, to_doc }) + } + /// [`Self::place_symbol_art`] for writers that do not apply clipping /// ([`NineSliceMap::place_unclipped`]). pub fn place_symbol_art_unclipped(&self, name: &str, art: &Node, xf: Affine) -> Option { match self.symbol_slice_map(name, xf) { - Some(m) => { - let m = NineSliceMap { clips: false, ..m }; - (m.most_placed(art, self) <= MAX_SYMBOL_NODES).then(|| m.place(art, self)) - } + Some(m) => (m.most_placed_unclipped(art, self) <= MAX_SYMBOL_NODES).then(|| m.place_unclipped(art, self)), None => Some(transformed(art, xf)), } } @@ -1072,6 +1186,9 @@ mod tests { let bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 40.0, 100.0, 60.0)), Appearance::basic(gradient, Paint::None, 0.0)); assert_eq!(m.most_placed(&bar, &d), 10); assert_eq!(m.place(&bar, &d).count(), 10); + // Unclipped, as DXF writes it, the bar is cut at the guides and stays one path. + assert_eq!(m.most_placed_unclipped(&bar, &d), 1); + assert_eq!(m.place_unclipped(&bar, &d).count(), 1); // Art nested past the depth limit, clipped in all nine cells. let mut deep = square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0); for _ in 0..=MAX_SLICE_DEPTH { @@ -1279,4 +1396,76 @@ mod tests { let v = inst.visual_bounds_with(&d.symbol_boxes()); assert!(v.is_some_and(|b| near(b, whole)), "{v:?}"); } + + /// Symbol "S" of [`markers`] edited in symbol editing mode, its art placed by `xf`. + fn editing(guides: Option, xf: Affine) -> Document { + let mut d = markers(guides); + let layer = d.layers[0].id; + d.symbol_edit = Some(crate::SymbolEdit { + symbol: "S".into(), + layer, + instance: None, + xf, + group: true, + changed: false, + back_isolation: None, + back_layer: None, + }); + d + } + + fn near_point(a: Point, b: Point) -> bool { + (a - b).hypot() < 1e-9 + } + + /// While a symbol with guides is edited, its four guides cross its art's box from edge to edge + /// where the art shows on the editing layer: here at 25 % in, the art doubled and moved. + #[test] + fn the_edited_symbols_guides_cross_its_art_where_the_editing_layer_shows_it() { + let xf = Affine::translate((10.0, 20.0)) * Affine::scale(2.0); + let e = editing(Some(SymbolNineSlice::default()), xf).edited_nine_slice().unwrap(); + assert_eq!(e.symbol, "S"); + let want = [ + (NineSliceGuide::Left, Point::new(60.0, 20.0), Point::new(60.0, 220.0)), + (NineSliceGuide::Top, Point::new(10.0, 70.0), Point::new(210.0, 70.0)), + (NineSliceGuide::Right, Point::new(160.0, 20.0), Point::new(160.0, 220.0)), + (NineSliceGuide::Bottom, Point::new(10.0, 170.0), Point::new(210.0, 170.0)), + ]; + for ((g, a, b), (wg, wa, wb)) in e.lines().into_iter().zip(want) { + assert!(g == wg && near_point(a, wa) && near_point(b, wb), "{g:?} {a:?} {b:?}"); + } + // Outside the mode, without guides, or for a symbol that is gone: no guides. + assert!(markers(Some(SymbolNineSlice::default())).edited_nine_slice().is_none()); + assert!(editing(None, xf).edited_nine_slice().is_none()); + let mut d = editing(Some(SymbolNineSlice::default()), xf); + d.symbols.clear(); + assert!(d.edited_nine_slice().is_none()); + } + + /// Moving a guide changes that guide alone, follows the pointer across its axis only, stays + /// within the art's box and stops short of the opposite guide. + #[test] + fn a_moved_guide_stays_in_the_art_and_short_of_the_opposite_guide() { + let xf = Affine::translate((10.0, 20.0)) * Affine::scale(2.0); + let e = editing(Some(SymbolNineSlice::default()), xf).edited_nine_slice().unwrap(); + let q = SymbolNineSlice { left: 0.25, top: 0.25, right: 0.25, bottom: 0.25 }; + assert_eq!(e.moved(NineSliceGuide::Left, Point::new(30.0, 999.0)), SymbolNineSlice { left: 0.1, ..q }); + assert_eq!(e.moved(NineSliceGuide::Bottom, Point::new(-50.0, 180.0)), SymbolNineSlice { bottom: 0.2, ..q }); + assert_eq!(e.moved(NineSliceGuide::Top, Point::new(0.0, -500.0)), SymbolNineSlice { top: 0.0, ..q }); + assert_eq!(e.moved(NineSliceGuide::Right, Point::new(1e6, 0.0)), SymbolNineSlice { right: 0.0, ..q }); + let crossed = e.moved(NineSliceGuide::Left, Point::new(200.0, 50.0)); + assert!((crossed.left - (0.75 - MIN_CENTER_BAND)).abs() < 1e-12, "{crossed:?}"); + assert!(SymbolNineSlice::new(crossed.left, crossed.top, crossed.right, crossed.bottom).is_some()); + assert_eq!(e.moved(NineSliceGuide::Top, Point::new(f64::NAN, f64::NAN)), q); + // Saved guides closer than the minimum center band keep their place when a drag does not + // move them closer. + let narrow = SymbolNineSlice::new(0.00005, 0.25, 0.9999, 0.25).unwrap(); + let e = editing(Some(narrow), xf).edited_nine_slice().unwrap(); + assert_eq!(e.moved(NineSliceGuide::Left, Point::new(200.0, 50.0)), narrow); + assert_eq!(e.moved(NineSliceGuide::Left, Point::new(0.0, 50.0)), SymbolNineSlice { left: 0.0, ..narrow }); + // A mirrored placement measures along the symbol's own axes. + let e = editing(Some(SymbolNineSlice::default()), Affine::scale_non_uniform(-1.0, 1.0)).edited_nine_slice().unwrap(); + assert!(near_point(e.line(NineSliceGuide::Left).0, Point::new(-25.0, 0.0))); + assert_eq!(e.moved(NineSliceGuide::Left, Point::new(-10.0, 0.0)), SymbolNineSlice { left: 0.1, ..q }); + } } diff --git a/crates/doc/src/symbols.rs b/crates/doc/src/symbols.rs index 7e371c670..2cf260cbe 100644 --- a/crates/doc/src/symbols.rs +++ b/crates/doc/src/symbols.rs @@ -23,6 +23,9 @@ const MAX_DEPTH: usize = 32; /// holding instances: past it, an estimate stands in ([`SymbolBoxes::effects_reach`]). const MAX_REACHES: usize = 4096; +/// A symbol's name and the bits of the linear part of a transform that places its art. +type Placing = (String, [u64; 4]); + /// A symbol's art measured in symbol space. #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct ArtBox { @@ -37,6 +40,9 @@ pub(crate) struct ArtBox { /// The widest stroke of the objects with a visible brushed stroke in the art, or in the art /// of a symbol an instance in it shows: `None` when no object there has one. pub(crate) brush: Option, + /// Whether the art holds an instance of a symbol with 9-slice guides, or of a symbol whose art + /// holds one at any depth. + pub(crate) sliced: bool, } /// The art boxes of a document's symbols, which [`Node::geometric_bounds_with`], @@ -73,6 +79,14 @@ pub struct SymbolBoxes<'a> { unplaced: RefCell>, /// How many times [`Self::measure_art`] has run a measure on symbol art ([`Self::art_measured`]). runs: Cell, + /// The boxes [`Self::sliced_visual`] has measured, by symbol name and the bits of the linear + /// part of the transform that placed the art. + sliced: RefCell>>, + /// How many objects of symbol art [`Self::sliced_visual`] has placed, the art of opacity masks + /// included. + sliced_placed: Cell, + /// The most objects of symbol art [`Self::sliced_visual`] places: [`MAX_SYMBOL_NODES`]. + sliced_most: u64, } impl Document { @@ -90,6 +104,9 @@ impl Document { farthest: RefCell::default(), unplaced: RefCell::default(), runs: Cell::new(0), + sliced: RefCell::default(), + sliced_placed: Cell::new(0), + sliced_most: MAX_SYMBOL_NODES, } } } @@ -102,7 +119,11 @@ impl<'a> SymbolBoxes<'a> { /// Makes these boxes measure every symbol instance as [`Node::reach_bounds_with`] does, by its /// art box alone. An instance that scales or turns stroked art then measures a little larger - /// than with [`Node::visual_bounds_with`], and its art is not transformed to measure it. + /// than with [`Node::visual_bounds_with`], and its art is not transformed to measure it. The + /// visual box of an instance placed through 9-slice guides holds the art box placed by each + /// cell's map ([`crate::NineSliceMap::placement_bounds`]). An instance whose art holds an + /// instance of a symbol with 9-slice guides at any depth is measured as its art placed by its + /// transform ([`Self::sliced_visual`]). pub fn quick(self) -> Self { self.quick.set(true); self @@ -319,13 +340,64 @@ impl<'a> SymbolBoxes<'a> { if self.quick.get() { return None; } + self.place_and_measure(name, xf, most) + } + + /// [`Self::placed_visual`] in boxes made by [`Self::quick`] and while symbol art is measured + /// too, for a symbol whose art holds an instance of a symbol with 9-slice guides at any depth: + /// where that instance draws its art depends on the transform of each instance above it. The + /// art is placed once for each symbol and linear part of `xf`, and the box is moved by the rest + /// of `xf`. `None` for other symbols, when [`Self::placed_visual`] is `None` outside quick + /// boxes, and when placing the art would take the objects of the art these boxes have placed + /// for it past [`MAX_SYMBOL_NODES`], the art placed to measure the instances in placed art + /// included. The renderer draws no more objects of symbol art than that in one frame. + pub(crate) fn sliced_visual(&self, name: &str, xf: Affine) -> Option { + if !self.art(name)?.sliced { + return None; + } + let [a, b, c, d, e, f] = xf.as_coeffs(); + let (linear, moved) = (Affine::new([a, b, c, d, 0.0, 0.0]), Affine::translate((e, f))); + let key = (name.to_string(), [a, b, c, d].map(f64::to_bits)); + let cached = self.sliced.borrow().get(&key).copied(); + let v = match cached { + Some(v) => v, + None => { + // The most objects placing makes, the art of opacity masks included, is set aside + // while the art is placed and measured, and what placing did not make is given back. + let art = &self.doc.symbols.iter().find(|s| s.name == name)?.art; + let most = match self.doc.symbol_slice_map(name, linear) { + Some(m) => m.most_placed(art, self.doc), + None => art.count_with_masks() as u64, + }; + let used = self.sliced_placed.get(); + if used.saturating_add(most) > self.sliced_most { + return None; + } + self.sliced_placed.set(used + most); + let (v, placed) = self.place_and_count(name, linear, MAX_SYMBOL_NODES).unwrap_or((None, 0)); + self.sliced_placed.set(self.sliced_placed.get().saturating_sub(most.saturating_sub(placed))); + self.sliced.borrow_mut().insert(key, v); + v + } + }; + v.map(|v| moved.transform_rect_bbox(v)) + } + + /// The box [`Self::placed_visual_within`] returns outside quick boxes. + fn place_and_measure(&self, name: &str, xf: Affine, most: u64) -> Option { + self.place_and_count(name, xf, most).and_then(|(v, _)| v) + } + + /// [`Self::place_and_measure`], with the number of objects of the placed art, the art of + /// opacity masks included. `None` when the art is not placed. + fn place_and_count(&self, name: &str, xf: Affine, most: u64) -> Option<(Option, u64)> { self.measure_art(name, |art| { let art = match self.doc.symbol_slice_map(name, xf) { Some(m) if m.most_placed(art, self.doc) > most => return None, Some(m) => m.place(art, self.doc), None => self.doc.place_symbol_art(name, art, xf)?, }; - self.in_art(|| art.visual(Some(self))) + Some((self.in_art(|| art.visual(Some(self))), art.count_with_masks() as u64)) }) .flatten() } @@ -383,6 +455,7 @@ fn art_box(art: &Arc, symbols: &SymbolBoxes) -> Option { let reach = crate::live::max_outset(std::slice::from_ref(art), Some(symbols)); let mut effects = false; let mut brush: Option = None; + let mut sliced = false; art.walk(&mut |n| { let mut own = n.appearance.effects.iter().chain(n.appearance.items.iter().flat_map(|i| i.effects().iter())); effects |= own.any(|e| e.visible); @@ -395,9 +468,10 @@ fn art_box(art: &Arc, symbols: &SymbolBoxes) -> Option { if let Some(w) = symbols.brush_width(symbol) { brush = Some(brush.map_or(w, |b| b.max(w))); } + sliced |= symbols.doc.symbol_nine_slice(symbol).is_some() || symbols.art(symbol).is_some_and(|a| a.sliced); } }); - Some(ArtBox { geometric, visual, reach, effects, brush }) + Some(ArtBox { geometric, visual, reach, effects, brush, sliced }) } #[cfg(test)] @@ -447,6 +521,100 @@ mod tests { assert_eq!(scaled.reach_bounds_with(&s), Some(Rect::new(0.0, 0.0, 60.0, 30.0).inflate(reach, reach))); } + #[test] + fn quick_boxes_hold_art_that_9_slice_guides_move_past_the_instance() { + // A 100 × 100 frame with guides a quarter in, holding an instance of a bar from x 10 to 70. + // Scaled 2 times across, the bar crosses the left guide and is placed by the middle + // column's map, x → 3x − 50: it draws from x −20 to 160, past the frame's left edge at 0. + let mut d = Document::new(200.0, 200.0); + let bar = rect(&mut d, Rect::new(10.0, 40.0, 70.0, 60.0), 0.0); + symbol(&mut d, "Bar", vec![bar]); + let frame = rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0); + let inner = instance(&mut d, "Bar", Affine::IDENTITY); + symbol(&mut d, "Panel", vec![frame, inner]); + d.set_symbol_nine_slice("Panel", Some(crate::SymbolNineSlice::default())); + let panel = instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0)); + let exact = panel.visual_bounds_with(&d.symbol_boxes()).unwrap(); + assert_eq!(exact, Rect::new(-20.0, 0.0, 200.0, 100.0)); + let quick = panel.visual_bounds_with(&d.symbol_boxes().quick()).unwrap(); + assert_eq!(quick.union(exact), quick, "{quick:?} holds {exact:?}"); + } + + #[test] + fn quick_boxes_hold_art_that_nested_9_slice_guides_move_past_the_instance() { + // The panel of the test above, in a symbol without guides: once at its natural size and + // once scaled 2 times across. Where the bar draws depends on the transform the holder's + // instance adds to the panel's. + let mut d = Document::new(200.0, 200.0); + let bar = rect(&mut d, Rect::new(10.0, 40.0, 70.0, 60.0), 0.0); + symbol(&mut d, "Bar", vec![bar]); + let frame = rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0); + let inner = instance(&mut d, "Bar", Affine::IDENTITY); + symbol(&mut d, "Panel", vec![frame, inner]); + d.set_symbol_nine_slice("Panel", Some(crate::SymbolNineSlice::default())); + let natural = instance(&mut d, "Panel", Affine::IDENTITY); + symbol(&mut d, "Holder", vec![natural]); + let wide = instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0)); + symbol(&mut d, "Wide holder", vec![wide]); + // The holder scaled 2 times across scales the panel 2 times across: the bar draws from x + // −20 to 160, as in the test above. + let holder = instance(&mut d, "Holder", Affine::scale_non_uniform(2.0, 1.0)); + let quick = holder.visual_bounds_with(&d.symbol_boxes().quick()).unwrap(); + let drawn = Rect::new(-20.0, 40.0, 160.0, 60.0); + assert_eq!(quick.union(drawn), quick, "{quick:?} holds {drawn:?}"); + let wide_holder = instance(&mut d, "Wide holder", Affine::scale(2.0)); + for n in [&holder, &wide_holder] { + let exact = n.visual_bounds_with(&d.symbol_boxes()).unwrap(); + assert!(exact.x0 < 0.0, "{exact:?} reaches past the frame's left edge"); + let quick = n.visual_bounds_with(&d.symbol_boxes().quick()).unwrap(); + assert_eq!(quick.union(exact), quick, "{quick:?} holds {exact:?}"); + let reach = n.reach_bounds_with(&d.symbol_boxes()).unwrap(); + assert_eq!(reach.union(exact), reach, "{reach:?} holds {exact:?}"); + } + } + + #[test] + fn quick_boxes_place_art_that_holds_9_slice_instances_a_bounded_number_of_times() { + // A frame with guides, and 16 levels of symbols that each hold an instance of the level + // below a little taller and one turned a little: the transforms that place the frame differ + // for each of the 2^16 ways down, so measuring every one would place art 2^17 times. + let mut d = Document::new(200.0, 200.0); + let frame = rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0); + symbol(&mut d, "Level 0", vec![frame]); + d.set_symbol_nine_slice("Level 0", Some(crate::SymbolNineSlice::default())); + let (taller, turned) = (Affine::scale_non_uniform(1.0, 1.01), Affine::rotate(0.1)); + for i in 1..=16 { + let below = format!("Level {}", i - 1); + let pair = vec![instance(&mut d, &below, taller), instance(&mut d, &below, turned)]; + symbol(&mut d, &format!("Level {i}"), pair); + } + let top = instance(&mut d, "Level 16", taller); + // With room for 5,000 objects: each placement places at least one. + let s = SymbolBoxes { sliced_most: 5000, ..d.symbol_boxes().quick() }; + assert!(top.visual_bounds_with(&s).is_some()); + assert!(s.sliced_placed.get() <= 5000, "{} objects", s.sliced_placed.get()); + assert!(s.art_measured() <= 5000 + d.symbols.len(), "{} measures", s.art_measured()); + // Instances whose transforms differ only by a move place the art once. + let level = instance(&mut d, "Level 1", taller); + let moved = instance(&mut d, "Level 1", Affine::translate((5.0, 7.0)) * taller); + let s = d.symbol_boxes().quick(); + let b = level.visual_bounds_with(&s).unwrap(); + let measured = s.art_measured(); + assert_eq!(moved.visual_bounds_with(&s), Some(Affine::translate((5.0, 7.0)).transform_rect_bbox(b))); + assert_eq!(s.art_measured(), measured); + // The art of an opacity mask counts toward the limit: a group holding the framed + // instance, masked by a group of 10 rectangles, is 13 objects. + let mut framed = instance(&mut d, "Level 0", Affine::IDENTITY); + let squares = (0..10).map(|_| Arc::new(rect(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), 0.0))).collect(); + let mask = Node::group(d.alloc_id(), squares); + framed.extras_mut().mask = Some(Box::new(crate::OpacityMask::new(mask, true))); + symbol(&mut d, "Masked", vec![framed]); + let masked = instance(&mut d, "Masked", taller); + let s = d.symbol_boxes().quick(); + assert!(masked.visual_bounds_with(&s).is_some()); + assert_eq!(s.sliced_placed.get(), 13); + } + #[test] fn missing_symbols_and_symbols_inside_themselves_measure_as_the_square() { let mut d = Document::new(200.0, 200.0); diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 37e2d39bc..4b75b0149 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -451,3 +451,64 @@ fn pica_form_rejects_exponent_notation() { assert_eq!(Unit::Points.parse(s), None, "{s}"); } } + +/// Symbol editing of symbol "S" on `layer`. +fn symbol_edit_on(layer: NodeId) -> SymbolEdit { + SymbolEdit { + symbol: "S".into(), + layer, + instance: None, + xf: Affine::IDENTITY, + group: false, + changed: false, + back_isolation: None, + back_layer: None, + } +} + +/// The opacity-mask and symbol editing layers are left out of the saved document wherever they +/// are, also when `layer.move` made them sublayers of another layer. +#[test] +fn edit_mode_layers_are_dropped_as_sublayers_too() { + let mut d = Document::new(612.0, 792.0); + let host = d.layers[0].id; + let mask = d.add_layer(Some("Mask")); + let symbol = d.add_layer(Some("Symbol")); + d.move_node(mask, Some(host), 0).unwrap(); + d.move_node(symbol, Some(host), usize::MAX).unwrap(); + let object = d.alloc_id(); + d.mask_edit = Some(MaskEdit { object, layer: mask }); + d.symbol_edit = Some(symbol_edit_on(symbol)); + let saved = d.without_edit_modes(); + assert!(saved.node(mask).is_none(), "the mask editing layer is left out"); + assert!(saved.node(symbol).is_none(), "the symbol editing layer is left out"); + assert!(saved.node(host).is_some_and(|l| l.children().is_some_and(|c| c.is_empty()))); + assert!(saved.mask_edit.is_none() && saved.symbol_edit.is_none()); +} + +/// Symbol editing is never read from a file, so a layer that file data names as the editing layer +/// loads and stays in the document, and saving does not write the value again. +#[test] +fn symbol_edit_is_not_read_from_a_file() { + let mut d = Document::new(612.0, 792.0); + let layer = d.layers[0].id; + let mut v = serde_json::to_value(&d).unwrap(); + v["symbol_edit"] = serde_json::to_value(symbol_edit_on(layer)).unwrap(); + d = serde_json::from_value(v).unwrap(); + assert_eq!(d.symbol_edit, None); + assert!(d.extra.is_empty(), "not kept as an unknown key"); + d.drop_edit_modes(); + assert!(d.node(layer).is_some(), "the layer is kept"); + assert!(serde_json::to_value(&d).unwrap().get("symbol_edit").is_none(), "not saved again"); +} + +/// An opacity-mask editing id in file data that names art inside a layer, not a layer, leaves the +/// art in place. +#[test] +fn mask_edit_naming_art_leaves_it() { + let (mut d, a, b) = doc_with_rects(); + d.mask_edit = Some(MaskEdit { object: b, layer: a }); + d.drop_edit_modes(); + assert!(d.node(a).is_some() && d.node(b).is_some()); + assert_eq!(d.mask_edit, None); +} diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 064ecbda1..a2f21e856 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -16,7 +16,7 @@ mod wrap; pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape}; use char_style::yes; -pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, ScriptMetrics}; +pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, ScriptMetrics}; pub use para_style::{ Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, SingleWordJustify, diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index 035b92e34..731480fed 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -93,6 +93,10 @@ pub struct CharStyle { /// them. #[serde(default, rename = "charDirection", skip_serializing_if = "Option::is_none")] pub char_direction: Option, + /// Character panel menu › Kashidas: whether these characters may receive a kashida when + /// their paragraph is justified with Insert Kashidas. On by default, including old documents. + #[serde(default = "crate::yes", skip_serializing_if = "crate::skip::is_true")] + pub kashidas: bool, /// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the /// font's own proportional widths, OpenType `palt` (#966); in vertical type upright glyphs take /// its proportional heights, `vpal`. @@ -143,6 +147,28 @@ pub struct CharStyle { /// document's language (Document Setup). Saved only when set. #[serde(default, skip_serializing_if = "Option::is_none")] pub language: Option, + /// Another glyph of the font for the run's first character (Glyphs panel › Show › Alternates + /// for Current Selection). The text keeps the character; layout draws the chosen glyph while + /// the run starts with that character and is set in the face the choice names, and the + /// default glyph otherwise. Saved only when set. + #[serde(default, rename = "glyphAlternate", skip_serializing_if = "Option::is_none")] + pub glyph_alternate: Option, +} + +/// A glyph chosen for one character ([`CharStyle::glyph_alternate`]): the glyph id and the face it +/// belongs to, named by its PostScript name and version string, so that a glyph id is never used +/// in another font or another version of the font. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct GlyphChoice { + /// The character the glyph is drawn for. + pub ch: char, + /// The glyph id in the face. + pub gid: u32, + /// The face's PostScript name (`SourceSans3-Regular`). + pub font: String, + /// The face's version string (`Version 3.052`); empty when it has none. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub version: String, } /// What a character's font size measures ([`CharStyle::font_height_reference`]): the em square @@ -405,6 +431,7 @@ impl Default for CharStyle { position: CharPosition::Normal, char_align: CharAlign::RomanBaseline, char_direction: None, + kashidas: true, proportional_metrics: false, tate_chu_yoko: false, tcy_up_down: 0.0, @@ -415,6 +442,7 @@ impl Default for CharStyle { font_height_reference: FontHeightReference::EmBox, no_break: false, language: None, + glyph_alternate: None, } } } diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index 5ba5669e6..70e953922 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -200,14 +200,16 @@ impl TextObject { } /// The list that the characters before byte `at` start ([`ParaList::typed`]) with the byte /// where their paragraph starts: when the plain text from the start of the paragraph up to - /// `at` is a list's start followed by a space, and the paragraph is in no list. None otherwise. + /// `at` is a list's start followed by a space, and the paragraph is in no list. None otherwise, + /// and for a first paragraph that continues from the frame before + /// ([`TextObject::first_para_continues`]). pub fn typed_list_at(&self, at: usize) -> Option<(usize, ParaList)> { let text = self.plain_text(); let before = text.get(..at)?; let start = before.rfind('\n').map_or(0, |i| i + 1); let list = ParaList::typed(before[start..].strip_suffix(' ')?)?; let index = before.bytes().filter(|&b| b == b'\n').count(); - self.para_at(index).list.is_none().then_some((start, list)) + (!(index == 0 && self.first_para_continues) && self.para_at(index).list.is_none()).then_some((start, list)) } /// Apply `f` to the attributes of paragraphs `range` (indices; None = every paragraph). pub fn edit_paras(&mut self, range: Option>, mut f: impl FnMut(&mut ParaStyle)) { @@ -435,12 +437,16 @@ impl crate::Document { st.h_scale = num("h_scale").unwrap_or(st.h_scale); st.v_scale = num("v_scale").unwrap_or(st.v_scale); st.baseline_shift = num("baseline_shift").unwrap_or(st.baseline_shift); + st.tcy_up_down = num("tcyUpDown").unwrap_or(st.tcy_up_down); + st.tcy_left_right = num("tcyLeftRight").unwrap_or(st.tcy_left_right); st.keep_dimensions_in_range(); let kept = [ ("size", Some(st.size)), ("h_scale", Some(st.h_scale)), ("v_scale", Some(st.v_scale)), ("baseline_shift", Some(st.baseline_shift)), + ("tcyUpDown", Some(st.tcy_up_down)), + ("tcyLeftRight", Some(st.tcy_left_right)), ("leading", st.leading), ]; for (k, v) in kept { diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs index 04deae8ee..2a47ee453 100644 --- a/crates/doc/src/text/tests.rs +++ b/crates/doc/src/text/tests.rs @@ -239,6 +239,19 @@ fn typed_list_starts_are_found_at_a_paragraphs_start() { assert_eq!(t.typed_list_at(19), Some((16, letters))); } +/// In a threaded frame whose first paragraph continues from the frame before, the text at the +/// frame's start is not the start of a paragraph, so typing a list's start there starts nothing. +/// The paragraphs after it still start lists. +#[test] +fn typed_list_starts_skip_a_continued_paragraph() { + let dot = ParaList::bulleted(Bullet::Dot); + let mut t = TextObject::point(Point::ZERO, "* Pears\n# ", CharStyle::default()); + assert_eq!(t.typed_list_at(2), Some((0, dot))); + t.first_para_continues = true; + assert_eq!(t.typed_list_at(2), None, "a continued paragraph starts no list"); + assert_eq!(t.typed_list_at(10), Some((8, dot))); +} + #[test] fn character_controls_reject_collapsed_and_nonfinite_transforms() { let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default()); diff --git a/crates/doc/src/twist.rs b/crates/doc/src/twist.rs new file mode 100644 index 000000000..8865fd2a9 --- /dev/null +++ b/crates/doc/src/twist.rs @@ -0,0 +1,231 @@ +//! The Twist effect's angle in saved documents. A positive angle turns clockwise on the page in +//! VectorCraft 0.21.0 and later; earlier releases turned it counterclockwise. Documents of version +//! 2 ([`TWIST_CLOCKWISE_SINCE`]) turn clockwise. Version 1 documents written before 0.21.0 have +//! their Twist angles negated when they open, so they draw as they were saved +//! ([`Document::migrate_twist_angles`], [`written_clockwise`]), and a save for an earlier app +//! negates them back ([`Document::store_legacy_twist_angles`]). Graphic style library files +//! (`.vcstyles`) of version 1 have their Twist angles negated when they are read +//! ([`crate::style_libs::read`]); [`crate::style_libs::write`] writes version 2. + +use std::sync::Arc; + +use serde_json::{Map, Value, json}; + +use crate::style_libs::StyleLibrary; +use crate::{Appearance, Document, Effect, Node}; + +/// The Twist effect's id. +pub const TWIST: &str = "distort.twist"; + +/// The first document version whose Twist angles turn clockwise for a positive value. +pub const TWIST_CLOCKWISE_SINCE: u32 = 2; + +/// The first release (major, minor) whose Twist turned clockwise for a positive angle. +const CLOCKWISE_RELEASE: (u64, u64) = (0, 21); + +/// Did the app named by a native file's `generator` (`"VectorCraft 0.21.0"`) turn Twist effects +/// clockwise for a positive angle? It did from 0.21.0. Any other value, or none, is an earlier +/// app (DrawCraft included). +pub fn written_clockwise(generator: Option<&str>) -> bool { + let Some(version) = generator.and_then(|g| g.strip_prefix("VectorCraft ")) else { return false }; + let mut parts = version.split('.').map(|p| p.parse::().ok()); + match (parts.next().flatten(), parts.next().flatten()) { + (Some(major), Some(minor)) => (major, minor) >= CLOCKWISE_RELEASE, + _ => false, + } +} + +/// The angle a Twist effect without a readable one draws with (`vectorcraft-effects` reads a +/// missing or unreadable `angle` as this). +const DEFAULT_ANGLE: f64 = 10.0; + +/// The key of `Document::unknown` that holds the brush definitions (`vectorcraft-brush`'s +/// `DOC_KEY`), whose art can carry effects. +const BRUSHES_KEY: &str = "brushes"; + +/// The angle a Twist effect with `params` draws with, read as `vectorcraft-effects` reads it: a +/// number, the number a string starts with, or a boolean as 1 or 0; anything else (or a value +/// that is not finite) is [`DEFAULT_ANGLE`]. +fn angle(params: &Value) -> f64 { + let v = match params.get("angle") { + Some(Value::Number(n)) => n.as_f64(), + Some(Value::String(s)) => { + let t: String = s.trim().chars().take_while(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E')).collect(); + t.parse().ok() + } + Some(Value::Bool(b)) => Some(if *b { 1.0 } else { 0.0 }), + _ => None, + }; + v.filter(|v| v.is_finite()).unwrap_or(DEFAULT_ANGLE) +} + +/// Write the negated angle a Twist effect with `params` draws with into `params`, as a number. +fn negate_params(params: &mut Value) { + // `0.0 - a` rather than `-a`: an angle of 0 stays 0, not -0. + let turned = Value::from(0.0 - angle(params)); + match params { + Value::Object(m) => { + m.insert("angle".into(), turned); + } + p => *p = json!({ "angle": turned }), + } +} + +fn has_twist(fx: &[Effect]) -> bool { + fx.iter().any(|e| e.id == TWIST) +} + +fn appearance_has_twist(a: &Appearance) -> bool { + has_twist(&a.effects) || a.items.iter().any(|i| has_twist(i.effects())) +} + +fn negate_appearance(a: &mut Appearance) { + for e in a.effects.iter_mut().chain(a.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut())) { + if e.id == TWIST { + negate_params(&mut e.params); + } + } +} + +/// The appearances a Shaper Group's face and edge edits paint with. +fn shaper_appearances(n: &Node) -> impl Iterator { + let s = n.extras().shaper.as_deref(); + let faces = s.into_iter().flat_map(|s| s.faces.iter().filter_map(|f| f.paint.as_ref())); + let edges = s.into_iter().flat_map(|s| s.edges.iter().filter_map(|e| e.paint.as_ref())); + faces.chain(edges) +} + +/// Does `n`'s subtree (opacity-mask art and Shaper edits included) hold a Twist effect? +fn node_has_twist(n: &Node) -> bool { + appearance_has_twist(&n.appearance) + || n.extras().mask.as_ref().is_some_and(|m| node_has_twist(&m.art)) + || shaper_appearances(n).any(appearance_has_twist) + || n.children().is_some_and(|c| c.iter().any(|c| node_has_twist(c))) +} + +/// Negate the Twist angles of `a`'s subtree, copying only the nodes on the way to them. +fn negate_node(a: &mut Arc) { + if !node_has_twist(a) { + return; + } + let n = Arc::make_mut(a); + if appearance_has_twist(&n.appearance) { + negate_appearance(&mut n.appearance); + } + if let Some(x) = n.extras_if_any() { + if let Some(m) = x.mask.as_mut() { + negate_node(&mut m.art); + } + if let Some(s) = x.shaper.as_mut() { + let faces = s.faces.iter_mut().filter_map(|f| f.paint.as_mut()); + for p in faces.chain(s.edges.iter_mut().filter_map(|e| e.paint.as_mut())) { + negate_appearance(p); + } + } + } + for c in n.children_mut().into_iter().flatten() { + negate_node(c); + } +} + +fn is_twist_json(m: &Map) -> bool { + m.get("id").and_then(Value::as_str) == Some(TWIST) +} + +/// Does JSON `v` (brush definitions) hold a Twist effect? +fn json_has_twist(v: &Value) -> bool { + match v { + Value::Object(m) => is_twist_json(m) || m.values().any(json_has_twist), + Value::Array(a) => a.iter().any(json_has_twist), + _ => false, + } +} + +/// Negate the Twist angles in JSON `v`: every object whose `id` is [`TWIST`] is a serialized +/// [`Effect`] (object ids are numbers). +fn negate_json(v: &mut Value) { + match v { + Value::Object(m) if is_twist_json(m) => negate_params(m.entry("params").or_insert(Value::Null)), + Value::Object(m) => m.values_mut().for_each(negate_json), + Value::Array(a) => a.iter_mut().for_each(negate_json), + _ => {} + } +} + +/// Negate every Twist angle of a graphic style library: its styles' and its patterns' art. +pub(crate) fn negate_library(lib: &mut StyleLibrary) { + for g in &mut lib.styles { + negate_appearance(&mut g.appearance); + } + for p in &mut lib.patterns { + p.art.iter_mut().for_each(negate_node); + } +} + +impl Document { + /// A document before version [`TWIST_CLOCKWISE_SINCE`] that holds a Twist effect: when + /// `written_clockwise` (asked only then; see [`written_clockwise`]) says the app that saved it + /// turned Twist counterclockwise, negate its Twist angles, so it draws as it was saved. A later + /// version is left as it is, and the document's version is not changed. It recurses through + /// the art, once per level of nesting. + pub fn migrate_twist_angles(&mut self, written_clockwise: impl FnOnce() -> bool) { + if self.version >= TWIST_CLOCKWISE_SINCE || !self.has_twist() || written_clockwise() { + return; + } + self.negate_twist_angles(); + } + + /// The reverse of [`Document::migrate_twist_angles`], for saving for an app that turned Twist + /// counterclockwise: negate the Twist angles and mark the document version 1. Call it on a + /// document whose angles turn clockwise, as every opened or new document's do, whatever + /// version another conversion for that save has marked it already. + pub fn store_legacy_twist_angles(&mut self) { + self.version = TWIST_CLOCKWISE_SINCE - 1; + if self.has_twist() { + self.negate_twist_angles(); + } + } + + /// Does any Twist effect sit where [`Document::negate_twist_angles`] negates them? + fn has_twist(&self) -> bool { + let mut roots = self + .layers + .iter() + .chain(self.symbols.iter().map(|s| &s.art)) + .chain(self.graph_designs.iter().map(|g| &g.art)) + .chain(self.patterns.iter().flat_map(|p| p.art.iter())) + .chain(self.pattern_edit.iter().filter_map(|pe| pe.original.as_ref()).flat_map(|p| p.art.iter())); + roots.any(|r| node_has_twist(r)) + || self.graphic_styles.iter().any(|g| appearance_has_twist(&g.appearance)) + || self.unknown.get(BRUSHES_KEY).is_some_and(json_has_twist) + } + + /// Negate every Twist angle: in the art of the layers, symbols, graph designs, pattern + /// swatches and the pattern definition that pattern editing's Cancel puts back (opacity-mask + /// art and Shaper edits included), in the graphic styles and in the brush definitions. + fn negate_twist_angles(&mut self) { + let roots = self + .layers + .iter_mut() + .chain(self.symbols.iter_mut().map(|s| &mut s.art)) + .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) + .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) + .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())); + for root in roots { + negate_node(root); + } + if self.graphic_styles.iter().any(|g| appearance_has_twist(&g.appearance)) { + for g in self.graphic_styles.iter_mut() { + negate_appearance(&mut g.appearance); + } + } + if self.unknown.get(BRUSHES_KEY).is_some_and(json_has_twist) + && let Some(b) = self.unknown.get_mut(BRUSHES_KEY) + { + negate_json(b); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/doc/src/twist/tests.rs b/crates/doc/src/twist/tests.rs new file mode 100644 index 000000000..699be66cb --- /dev/null +++ b/crates/doc/src/twist/tests.rs @@ -0,0 +1,186 @@ +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_geom::{Point, Rect, shapes}; + +use super::*; +use crate::shaper::{FaceEdit, FaceSelector, ShaperSpec}; +use crate::{GraphicStyle, OpacityMask, PatternDef, Symbol}; + +fn fx(id: &str, params: Value) -> Effect { + Effect { id: id.into(), params, visible: true } +} + +/// An appearance with a Twist of `params` on the object and on its fill, and a Roughen. +fn twisted_appearance(params: &Value) -> Appearance { + let mut a = Appearance::default_art(); + a.effects.push(fx(TWIST, params.clone())); + a.effects.push(fx("distort.roughen", json!({"size": 5}))); + a.items[0].effects_mut().push(fx(TWIST, params.clone())); + a +} + +fn twisted(d: &mut Document, params: &Value) -> Node { + Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), twisted_appearance(params)) +} + +/// A version 1 document with Twist effects of `params` everywhere one is stored: on a path, in a +/// group, in an opacity mask's art, in a Shaper edit, in a symbol, a pattern swatch and a graphic +/// style, and `brush` in a brush definition. Its first layer holds the art; its last has no +/// Twist. +fn document(params: &Value, brush: &Value) -> Document { + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + let path = twisted(&mut d, params); + d.insert(Some(l), 0, path).unwrap(); + let inner = twisted(&mut d, params); + let group = Node::group(d.alloc_id(), vec![Arc::new(inner)]); + d.insert(Some(l), 1, group).unwrap(); + let mask_art = twisted(&mut d, params); + let mut masked = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), Appearance::default_art()); + masked.extras_mut().mask = Some(Box::new(OpacityMask::new(mask_art, true))); + d.insert(Some(l), 2, masked).unwrap(); + let mut shaper = Node::group(d.alloc_id(), vec![]); + let face = FaceEdit { + selector: FaceSelector { sources: vec![0], anchor: Point::ZERO }, + erase: false, + erase_edges: false, + paint_source: None, + merge: None, + paint: Some(twisted_appearance(params)), + }; + shaper.extras_mut().shaper = Some(Box::new(ShaperSpec { faces: vec![face], edges: vec![] })); + d.insert(Some(l), 3, shaper).unwrap(); + let art = twisted(&mut d, params); + d.symbols.push(Symbol { name: "S".into(), art: Arc::new(art), dynamic: false }); + let art = twisted(&mut d, params); + d.patterns.push(PatternDef::new("P", vec![Arc::new(art)])); + d.graphic_styles.push(GraphicStyle::new("G", twisted_appearance(params))); + d.unknown.insert(BRUSHES_KEY.into(), json!([{"name": "B", "art": {"id": 99, "appearance": {"effects": [brush]}}}])); + d.add_layer(None); + d.version = 1; + d +} + +/// The angle each Twist effect of the saved document draws with, in the order the JSON lists them. +fn angles(d: &Document) -> Vec { + fn walk(v: &Value, out: &mut Vec) { + match v { + Value::Object(m) => { + if is_twist_json(m) { + out.push(angle(m.get("params").unwrap_or(&Value::Null))); + } + m.values().for_each(|c| walk(c, out)); + } + Value::Array(a) => a.iter().for_each(|c| walk(c, out)), + _ => {} + } + } + let mut out = vec![]; + walk(&serde_json::to_value(d).unwrap(), &mut out); + out +} + +#[test] +fn a_version_1_document_from_before_0_21_has_its_twist_angles_negated() { + let mut d = document(&json!({"angle": 30}), &json!({"id": TWIST})); + let before = angles(&d); + // Two per path, shaper edit and style; one in the brush. + assert_eq!(before.len(), 15, "{before:?}"); + assert_eq!(before.iter().filter(|&&a| a == DEFAULT_ANGLE).count(), 1, "{before:?}"); + d.migrate_twist_angles(|| false); + assert_eq!(d.version, 1, "the caller sets the version"); + assert_eq!(angles(&d), before.iter().map(|a| -a).collect::>()); + let roughen = serde_json::to_string(&d).unwrap().matches(r#""id":"distort.roughen","params":{"size":5}"#).count(); + assert_eq!(roughen, 7, "other effects are left alone"); +} + +#[test] +fn a_document_that_turned_clockwise_is_left_as_it_is() { + let mut d = document(&json!({"angle": 30}), &json!({"id": TWIST, "params": {"angle": 30}})); + let before = d.clone(); + d.migrate_twist_angles(|| true); + assert_eq!(d, before, "version 1, written by 0.21.0 or later"); + d.version = 2; + let before = d.clone(); + d.migrate_twist_angles(|| panic!("a version 2 document's writer is not asked about")); + assert_eq!(d, before, "version 2"); +} + +#[test] +fn the_writer_is_asked_about_only_when_a_twist_is_stored() { + let mut d = Document::new(10.0, 10.0); + d.version = 1; + let before = d.clone(); + d.migrate_twist_angles(|| panic!("a document without a Twist needs no answer")); + assert_eq!(d, before); +} + +#[test] +fn angles_are_read_as_the_effect_reads_them() { + let read = |params: Value| { + let mut e = fx(TWIST, params); + negate_params(&mut e.params); + e.params + }; + assert_eq!(read(json!({"angle": 30, "x": 1})), json!({"angle": -30.0, "x": 1})); + assert_eq!(read(json!({"angle": "45 deg"})), json!({"angle": -45.0})); + assert_eq!(read(json!({"angle": true})), json!({"angle": -1.0})); + assert_eq!(read(json!({})), json!({"angle": -10.0})); + assert_eq!(read(Value::Null), json!({"angle": -10.0})); + let zero = read(json!({"angle": 0})); + assert!(zero["angle"].as_f64().is_some_and(|a| a == 0.0 && a.is_sign_positive()), "{zero}"); +} + +#[test] +fn only_the_art_holding_a_twist_is_copied() { + let mut d = document(&json!({"angle": 30}), &json!({"id": TWIST})); + let plain = d.layers.last().cloned().unwrap(); + let twisted = d.layers[0].clone(); + d.migrate_twist_angles(|| false); + assert!(Arc::ptr_eq(d.layers.last().unwrap(), &plain), "a layer without a Twist is not copied"); + assert!(!Arc::ptr_eq(&d.layers[0], &twisted)); +} + +#[test] +fn storing_for_an_earlier_app_and_reading_back_round_trips() { + let original = document(&json!({"angle": 30.0}), &json!({"id": TWIST, "params": {"angle": -12.5}})); + let mut d = original.clone(); + d.migrate_twist_angles(|| false); + d.version = 2; + let current = d.clone(); + assert_eq!(angles(¤t), angles(&original).iter().map(|a| -a).collect::>()); + d.store_legacy_twist_angles(); + assert_eq!(d, original); + d.migrate_twist_angles(|| false); + d.version = 2; + assert_eq!(d, current); + // Another conversion for the same save may have marked the document version 1 already. + d.version = 1; + d.store_legacy_twist_angles(); + assert_eq!(d, original); +} + +#[test] +fn a_version_1_style_library_has_its_twist_angles_negated() { + let lib = StyleLibrary { name: "L".into(), styles: vec![GraphicStyle::new("G", twisted_appearance(&json!({"angle": 30.0})))], patterns: vec![] }; + let text = crate::style_libs::write(&lib); + assert!(text.contains(r#""version": 2"#), "{text}"); + assert_eq!(crate::style_libs::read(&text, "x").unwrap(), lib, "a version 2 file reads as written"); + let old = crate::style_libs::read(&text.replacen(r#""version": 2"#, r#""version": 1"#, 1), "x").unwrap(); + let fx = &old.styles[0].appearance.effects; + assert_eq!(fx[0].params, json!({"angle": -30.0})); + assert_eq!(fx[1], lib.styles[0].appearance.effects[1]); + assert_eq!(old.styles[0].appearance.items[0].effects()[0].params, json!({"angle": -30.0})); +} + +#[test] +fn releases_from_0_21_turned_twist_clockwise() { + for g in ["VectorCraft 0.21.0", "VectorCraft 0.21.4", "VectorCraft 0.22.0-dev", "VectorCraft 1.0.0"] { + assert!(written_clockwise(Some(g)), "{g}"); + } + for g in ["VectorCraft 0.20.0", "VectorCraft 0.9.12", "DrawCraft 0.1.0", "VectorCraft", "VectorCraft x.y", "vectorcraft 0.21.0"] { + assert!(!written_clockwise(Some(g)), "{g}"); + } + assert!(!written_clockwise(None)); +} diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 7c9f649c7..b67279bbb 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -103,6 +103,23 @@ fn shape_modes_follow_the_pathfinder_panel() { assert_eq!(div.len(), 3); } +/// Divide and Outline draw no piece of a member without fill or stroke, the same as when such +/// pieces are left out. +#[test] +fn divide_and_outline_draw_nothing_for_unpainted_members() { + let unpainted = + Arc::new(Node::path(NodeId(2), shapes::rectangle(Rect::new(5.0, 0.0, 15.0, 10.0)), Appearance::basic(Paint::None, Paint::None, 0.0))); + for effect in ["pathfinder.divide", "pathfinder.outline"] { + let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), unpainted.clone()]); + g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true }); + let out = pathfinder_children(&g, None).unwrap(); + assert!(out.iter().any(|n| n.id == NodeId(1)) && out.iter().any(|n| n.id == NodeId(2)), "{effect}: {out:#?}"); + for n in out.iter().filter(|n| n.id == NodeId(2)) { + assert!(n.appearance.items.iter().all(|i| i.paint().is_none()), "{effect}: {n:#?}"); + } + } +} + /// Returns an "H" in blue type at 100 pt on a baseline at y = 80, with its stems on either /// side of x = 100. fn type_h() -> Node { diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 9af1cfffd..fbbf3b0a9 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -77,7 +77,7 @@ pub use lighting_presets::{LIGHTING_PRESETS, LightingPreset, lighting_preset_par pub use live::{expand_live, expand_live_deep, expanded_live_group, outline_object, text_outliner}; pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; -pub use raster::{RASTERIZE, RasterFx, Rasterize, outset, raster_effects}; +pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects}; pub use reshape::{ SYMBOL_ART_MAX_NODES, expand_outlined, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index f8590bbb2..a1d8df2d6 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -366,7 +366,7 @@ pub fn blend_mode(s: &str) -> BlendMode { } /// An effect's `color` parameter (`"#rrggbb"`, `[r, g, b]` 0..1 or a colour object). -pub(crate) fn color_param(p: &Value, default: Color) -> Color { +pub fn color_param(p: &Value, default: Color) -> Color { match p.get("color") { Some(Value::String(s)) => Color::from_hex(s).unwrap_or(default), Some(Value::Array(a)) if a.len() >= 3 => { diff --git a/crates/effects/src/stroke.rs b/crates/effects/src/stroke.rs index 0760c38a2..2ae452ed5 100644 --- a/crates/effects/src/stroke.rs +++ b/crates/effects/src/stroke.rs @@ -24,7 +24,7 @@ use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer}; pub(crate) use vectorcraft_geom::bez::{kink, segments, subpaths, tangent, unit}; pub use arrow::Arrow; -pub use dash::{Dashed, Dot, dash, dot_outline}; +pub use dash::{Dashed, Dot, dash, dash_kept, dot_outline}; pub use gradient::{Slice, WrittenSlices, gradient_meshes, gradient_slices, written_slices}; pub use outline::{OUTLINE_TOL, Written, WrittenShape, for_writer, is_plain, outline_region}; pub use width::width_outline; @@ -111,11 +111,7 @@ pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> B } out } - (None, Some(d)) => { - let mut out = kurbo::stroke(d.path.iter(), &style(st, width), &kurbo::StrokeOpts::default(), tol); - out.extend(dot_outline(&d.dots, width, st.cap, tol).iter()); - out - } + (None, Some(d)) => dashed_outline(&d, st, width, tol), (None, None) => { let mut out = plain_outline(line, &style(st, width), tol); out.extend(dot_outline(&point_dots(line), width, st.cap, tol).iter()); @@ -124,6 +120,28 @@ pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> B } } +/// [`line_outline`] of a dashed line without a width profile, made of only the dashes and dots on +/// the segments of `line` that `keep` accepts ([`dash_kept`]). `None` when `st` has no dash +/// pattern or has a width profile, or when its pattern draws the line solid. +pub fn dashed_line_outline_kept(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64, keep: &dyn Fn(&PathSeg) -> bool) -> Option { + if st.profile.is_some() { + return None; + } + let d = dash_kept(line, st.dash.as_ref()?, keep)?; + if width <= 0.0 || !width.is_finite() { + return Some(BezPath::new()); + } + Some(dashed_outline(&d, st, width, tol.max(1e-4))) +} + +/// The filled outline of the dashes and dots `d` of a stroke `st` without a width profile, at +/// `width` and flattened/fitted to `tol`. +fn dashed_outline(d: &Dashed, st: &StrokeLayer, width: f64, tol: f64) -> BezPath { + let mut out = kurbo::stroke(d.path.iter(), &style(st, width), &kurbo::StrokeOpts::default(), tol); + out.extend(dot_outline(&d.dots, width, st.cap, tol).iter()); + out +} + /// The subpaths of `line` that never leave their first point (`m` then `L` to the same point, as /// `.ai` files write dots, #1613), as dots: kurbo strokes nothing for them, where PDF and SVG /// viewers paint the cap. They lie along the x axis, which a projecting cap's square follows diff --git a/crates/effects/src/stroke/dash.rs b/crates/effects/src/stroke/dash.rs index 5c47c0743..f660a6c8f 100644 --- a/crates/effects/src/stroke/dash.rs +++ b/crates/effects/src/stroke/dash.rs @@ -45,6 +45,14 @@ pub struct Dashed { /// entry, as in SVG and PDF) or has no positive length (the line is solid), or is too fine to draw /// (then the line is drawn solid too). pub fn dash(bp: &BezPath, d: &Dash) -> Option { + dash_kept(bp, d, &|_| true) +} + +/// [`dash`] with only the dashes and dots on the segments of `bp` that `keep` accepts. The pattern +/// runs along every segment as in [`dash`], so a kept dash lies where [`dash`] puts it: a dash +/// running onto a segment left out ends where that segment starts and goes on from where the next +/// kept segment starts. Segments left out still count toward the limit on the number of dashes. +pub fn dash_kept(bp: &BezPath, d: &Dash, keep: &dyn Fn(&PathSeg) -> bool) -> Option { if d.pattern.iter().any(|v| !v.is_finite() || *v < 0.0) || !d.offset.is_finite() { return None; } @@ -57,6 +65,10 @@ pub fn dash(bp: &BezPath, d: &Dash) -> Option { pat.extend_from_within(..); } let period: f64 = pat.iter().sum(); + // Finite entries can still sum to infinity, which would never leave the phase loop below. + if !period.is_finite() { + return None; + } let plans: Vec = subpaths(bp) .into_iter() .map(|(r, closed)| { @@ -78,7 +90,7 @@ pub fn dash(bp: &BezPath, d: &Dash) -> Option { let phase = Phase { ix: ix0, rem: pat[ix0] - off, on: ix0 % 2 == 0 }; let mut out = Dashed::default(); for plan in &plans { - dash_subpath(&mut out, plan, &pat, phase); + dash_subpath(&mut out, plan, &pat, phase, keep); } Some(out) } @@ -171,7 +183,7 @@ impl Piece { } } -fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) { +fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep: &dyn Fn(&PathSeg) -> bool) { let first_dot = out.dots.len(); let total: f64 = plan.lens.iter().sum(); // Fraction of the subpath's length at `x` along the plan. @@ -184,7 +196,7 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) { }; let mut dashes: Vec = vec![]; let mut cur = Piece::default(); - let (mut cuts, mut acc, mut starts_on) = (0, 0.0, false); + let (mut cuts, mut acc, mut starts_on, mut left_out) = (0, 0.0, false, false); for (i, (seg, &len)) in plan.segs.iter().zip(&plan.lens).enumerate() { let k = plan.scale[i]; if plan.restart[i] { @@ -195,21 +207,30 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) { } ph = Phase { ix: 0, rem: pat[0] * k / 2.0, on: true }; } + // A segment left out ends the dash running onto it; the pattern still advances along it. + let kept = keep(seg); + if !kept { + left_out = true; + if !cur.segs.is_empty() { + cur.end = acc; + dashes.push(std::mem::take(&mut cur)); + } + } if i == 0 { - starts_on = ph.on && ph.rem > 0.0; + starts_on = kept && ph.on && ph.rem > 0.0; } let mut s = 0.0; loop { let left = len - s; if ph.rem > left + EPS { - if ph.on && left > EPS { + if kept && ph.on && left > EPS { cur.push(sub(seg, len, s, len), acc + s); } ph.rem -= left; break; } let end = (s + ph.rem).min(len); - if ph.on { + if kept && ph.on { if pat[ph.ix] > 0.0 { if end > s { cur.push(sub(seg, len, s, end), acc + s); @@ -236,6 +257,7 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) { && plan.restart[0] && pat[0] <= 0.0 && let Some(seg) = plan.segs.last() + && keep(seg) { push_dot(out, first_dot, Dot { at: seg.end(), dir: tangent(seg, 1.0), t: 1.0 }); } @@ -245,7 +267,7 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) { dashes.push(cur); } if plan.closed { - if cuts == 0 && ends_on { + if cuts == 0 && ends_on && !left_out { // One dash all the way round: keep it closed so the start gets a join, not caps. let d = dashes.pop().unwrap_or_default(); write(&mut out.path, &d.segs); diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index 067064950..44efdb327 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -21,6 +21,11 @@ fn stroke(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> StrokeLayer { st } +#[test] +fn dash_period_overflowing_to_infinity_is_solid() { + assert!(dash(&line(0.0, 60.0), &dashed(&[f64::MAX, f64::MAX], -1.0)).is_none()); +} + fn dashed(pattern: &[f64], offset: f64) -> Dash { Dash { pattern: pattern.to_vec(), offset, align_corners: false } } @@ -105,6 +110,80 @@ fn dashes_follow_arc_length_and_join_across_the_start_of_closed_paths() { assert!(dash(&line(0.0, 1e6), &dashed(&[0.001, 0.001], 0.0)).is_none()); } +/// The dashes of `d` as (start x, end x), for dashes along the x axis. +fn spans_x(d: &Dashed) -> Vec<(f64, f64)> { + let mut out = vec![]; + for el in d.path.elements() { + match el { + PathEl::MoveTo(p) => out.push((p.x, p.x)), + PathEl::LineTo(p) => { + if let Some(last) = out.last_mut() { + last.1 = p.x; + } + } + _ => {} + } + } + out +} + +/// Ten segments of 10 pt along the x axis from 0 to 100. +fn steps() -> BezPath { + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + for i in 1..=10 { + b.line_to((f64::from(i) * 10.0, 0.0)); + } + b +} + +/// Whether a segment lies outside x 30..60. +fn outside(s: &PathSeg) -> bool { + s.start().x >= 60.0 || s.end().x <= 30.0 +} + +#[test] +fn dashes_kept_on_some_segments_keep_the_phase_of_the_whole_line() { + // A period of 7 starting 2 into it: dashes at -2..2, 5..9, 12..16, … + let pattern = dashed(&[4.0, 3.0], 2.0); + let all = dash(&steps(), &pattern).unwrap(); + let kept = dash_kept(&steps(), &pattern, &|s| outside(s)).unwrap(); + // The dashes of the whole line, cut at x 30 and 60. + let cut: Vec<(f64, f64)> = + spans_x(&all).into_iter().flat_map(|(a, b)| [(a, b.min(30.0)), (a.max(60.0), b)]).filter(|(a, b)| b - a > 1e-9).collect(); + let got = spans_x(&kept); + assert_eq!(got.len(), cut.len(), "{got:?} against {cut:?}"); + for ((a, b), (c, d)) in got.iter().zip(&cut) { + assert!(near(*a, *c, 1e-9) && near(*b, *d, 1e-9), "{got:?} against {cut:?}"); + } + // The dash after the gap starts where the whole line's does, at 61, not at 60. + assert!(got.iter().any(|(a, _)| near(*a, 61.0, 1e-9)), "{got:?}"); + // Dots on segments left out are left out (the dot at 60 ends a segment left out). + let dots = dash_kept(&steps(), &dashed(&[0.0, 5.0], 0.0), &|s| outside(s)).unwrap(); + let xs: Vec = dots.dots.iter().map(|d| d.at.x).collect(); + let want = [0.0, 5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 65.0, 70.0, 75.0, 80.0, 85.0, 90.0, 95.0, 100.0]; + assert!(xs.len() == want.len() && xs.iter().zip(want).all(|(x, w)| near(*x, w, 1e-9)), "{xs:?}"); + // Keeping every segment is the whole line's dashing, and keeping none leaves nothing. + assert_eq!(dash_kept(&steps(), &pattern, &|_| true).unwrap().path, all.path); + let none = dash_kept(&steps(), &pattern, &|_| false).unwrap(); + assert!(none.path.elements().is_empty() && none.dots.is_empty()); + // A pattern too fine for the whole line draws it solid, however few segments are kept. + assert!(dash_kept(&line(0.0, 1e6), &dashed(&[0.001, 0.001], 0.0), &|s| s.start().x < 1.0).is_none()); +} + +#[test] +fn a_closed_path_with_segments_left_out_is_dashed_in_open_pieces() { + // One dash all the way round: with the right side left out it is no longer closed. + let keep_left = |s: &PathSeg| s.start().x < 5.0 || s.end().x < 5.0; + let whole = dash_kept(&square(10.0), &dashed(&[100.0, 1.0], 0.0), &keep_left).unwrap(); + assert!(!is_closed(&whole.path), "{:?}", whole.path.to_svg()); + // The bottom side left out: of the 10 dashes, the one through the start point stays one dash, + // the one into the bottom side is cut and the two on it are left out. + let kept = dash_kept(&square(100.0), &dashed(&[20.0, 20.0], 10.0), &|s| s.start().y < 50.0 || s.end().y < 50.0).unwrap(); + assert_eq!(moves(&kept.path), 8, "{}", kept.path.to_svg()); + assert!(!kept.path.elements().iter().any(|e| matches!(e, PathEl::MoveTo(p) if p.distance(Point::ZERO) < 1e-9)), "{}", kept.path.to_svg()); +} + #[test] fn dot_outlines_take_the_cap_shape_and_wind_like_stroked_dashes() { let dots = [Dot { at: Point::new(10.0, 0.0), dir: Vec2::new(1.0, 0.0), t: 0.0 }]; diff --git a/crates/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index 87eca97d7..324fddfac 100644 --- a/crates/effects/src/stroke/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -114,13 +114,16 @@ pub(super) fn split_at(pts: &[Point], corner: &[bool], closed: bool, marks: &[f6 let seg_count = if closed { n } else { n.saturating_sub(1) }; let mut out = (Vec::with_capacity(n + marks.len()), Vec::with_capacity(n + marks.len())); let mut acc = 0.0; + // Sorted once, so each segment finds its marks by search, not by a pass over all of them. + let mut marks = marks.to_vec(); + marks.sort_by(f64::total_cmp); for i in 0..seg_count { let (a, b) = (pts[i], pts[(i + 1) % n]); let len = a.distance(b); out.0.push(a); out.1.push(corner[i]); - let mut inner: Vec = marks.iter().copied().filter(|d| *d > acc + 1e-9 && *d < acc + len - 1e-9).collect(); - inner.sort_by(f64::total_cmp); + let from = marks.partition_point(|d| *d <= acc + 1e-9); + let mut inner: Vec = marks.iter().skip(from).copied().take_while(|d| *d < acc + len - 1e-9).collect(); inner.dedup_by(|a, b| (*a - *b).abs() <= 1e-9); for d in inner { out.0.push(a.lerp(b, (d - acc) / len)); diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 87e2d48be..6789a2116 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -61,6 +61,8 @@ vectorcraft-testkit = { workspace = true } proptest = { workspace = true } # A variable font built in code (no font file is committed). vectorcraft-text = { workspace = true, features = ["test-fonts"] } +# How many times an open reads a PDF. +vectorcraft-pdf = { workspace = true, features = ["read-count"] } wat = { workspace = true } # Zstandard streams for the Illustrator editing data fuzz tests. ruzstd = { workspace = true } diff --git a/crates/engine/examples/anchor_bench.rs b/crates/engine/examples/anchor_bench.rs new file mode 100644 index 000000000..281464c0b --- /dev/null +++ b/crates/engine/examples/anchor_bench.rs @@ -0,0 +1,336 @@ +//! `cargo run --release -p vectorcraft-engine --example anchor_bench` prints the wall time of the +//! engine steps of editing anchors with Direct Selection, on four synthetic documents: one closed path of +//! 50,000 anchors, 2,000 closed paths of 50 anchors each, one open path of 200,000 anchors, and the closed +//! path of 50,000 anchors filled and with a dashed stroke. +//! `ITERS` sets the runs per step (default 7); each row is the median in ms. +// Example (dev tool): unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::hint::black_box; +use std::time::Instant; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, Node, NodeId}; +use vectorcraft_engine::tools::{Mods, PointerEvent, PointerKind}; +use vectorcraft_engine::{Session, ViewInfo}; +use vectorcraft_geom::{Affine, Anchor, PathData, Point, SubPath, Vec2}; +use vectorcraft_render::{RenderOptions, Renderer}; + +/// Anchors are 10 pt apart and their handles 3 pt long, along the path. +const STEP: f64 = 10.0; +const HANDLE: f64 = 3.0; + +/// A test document: its paths, an anchor to hover, click and drag, a point on a segment, and a +/// marquee from an empty point over about 10,000 anchors. +struct Shape { + name: &'static str, + doc: Document, + ids: Vec, + anchor: (NodeId, usize), + segment: Point, + marquee: (Point, Point), +} + +/// A closed path of `n` smooth anchors around `center`. +fn circle(center: Point, n: usize) -> PathData { + let r = n as f64 * STEP / std::f64::consts::TAU; + let anchors = (0..n) + .map(|i| { + let t = i as f64 / n as f64 * std::f64::consts::TAU; + let p = center + Vec2::new(r * t.cos(), r * t.sin()); + Anchor::smooth(p, p + Vec2::new(-t.sin(), t.cos()) * HANDLE) + }) + .collect(); + PathData::single(SubPath::new(anchors, true)) +} + +fn add(doc: &mut Document, path: PathData) -> NodeId { + let l = doc.layers.first().map(|l| l.id); + let id = doc.alloc_id(); + doc.insert(l, usize::MAX, Node::path(id, path, Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0))).unwrap(); + id +} + +fn one_outline() -> Shape { + let n = 50_000; + let mut doc = Document::new(1600.0, 1200.0); + let id = add(&mut doc, circle(Point::ZERO, n)); + let r = n as f64 * STEP / std::f64::consts::TAU; + let a = 72f64.to_radians(); + let mid = (n / 2) as f64 / n as f64 * std::f64::consts::TAU; + let next = (n / 2 + 1) as f64 / n as f64 * std::f64::consts::TAU; + let segment = Point::new(r * (mid.cos() + next.cos()) / 2.0, r * (mid.sin() + next.sin()) / 2.0); + Shape { + name: "one closed path, 50,000 anchors", + doc, + ids: vec![id], + anchor: (id, n / 2), + segment, + marquee: (Point::new(r + 50.0, -5.0), Point::new(r * a.cos() - 1.0, r * a.sin() + 1.0)), + } +} + +/// [`one_outline`] filled, with a 1 pt stroke of 6 pt dashes and 4 pt gaps. +fn dashed_outline() -> Shape { + let mut shape = one_outline(); + let mut look = Appearance::basic(Paint::solid(Color::rgb(0.8, 0.2, 0.2)), Paint::solid(Color::BLACK), 1.0); + for item in &mut look.items { + if let AppearanceItem::Stroke(st) = item { + st.dash = Some(Dash { pattern: vec![6.0, 4.0], offset: 0.0, align_corners: false }); + } + } + shape.doc.node_mut(shape.ids[0]).unwrap().appearance = look.into(); + shape.name = "one closed path, 50,000 anchors, filled, dashed stroke"; + shape +} + +fn many_paths() -> Shape { + let mut doc = Document::new(1600.0, 1200.0); + let ids: Vec = + (0..2000).map(|i| add(&mut doc, circle(Point::new((i % 50) as f64 * 200.0 + 100.0, (i / 50) as f64 * 200.0 + 100.0), 50))).collect(); + let r = 50.0 * STEP / std::f64::consts::TAU; + // Path 1,025 (row 20, column 25), its anchors 0 and 1. + let c = Point::new(25.0 * 200.0 + 100.0, 20.0 * 200.0 + 100.0); + let t = std::f64::consts::TAU / 50.0; + let segment = c + Vec2::new(r * (1.0 + t.cos()) / 2.0, r * t.sin() / 2.0); + Shape { + name: "2,000 closed paths, 50 anchors each", + doc, + anchor: (ids[1025], 0), + ids, + segment, + marquee: (Point::ZERO, Point::new(10_000.0, 800.0)), + } +} + +fn long_open_path() -> Shape { + let n = 200_000; + let mut doc = Document::new(1600.0, 1200.0); + let anchors = (0..n) + .map(|i| { + let p = Point::new(i as f64 * STEP, 0.0); + Anchor::smooth(p, p + Vec2::new(HANDLE, 0.0)) + }) + .collect(); + let id = add(&mut doc, PathData::single(SubPath::new(anchors, false))); + Shape { + name: "one open path, 200,000 anchors", + doc, + ids: vec![id], + anchor: (id, n / 2), + segment: Point::new((n / 2) as f64 * STEP + STEP / 2.0, 0.0), + marquee: (Point::new(-5.0, -20.0), Point::new(99_995.0, 30.0)), + } +} + +fn iters() -> usize { + std::env::var("ITERS").ok().and_then(|v| v.parse().ok()).unwrap_or(7).max(1) +} + +fn median(mut ms: Vec) -> f64 { + ms.sort_by(f64::total_cmp); + ms[ms.len() / 2] +} + +fn time(mut f: impl FnMut()) -> f64 { + median( + (0..iters()) + .map(|_| { + let t = Instant::now(); + f(); + t.elapsed().as_secs_f64() * 1000.0 + }) + .collect(), + ) +} + +fn ev(kind: PointerKind, p: Point) -> PointerEvent { + PointerEvent::new(kind, p.x, p.y) +} + +fn exec(s: &mut Session, cmd: &str, p: Value) { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")); +} + +fn anchor_at(s: &Session, (id, ai): (NodeId, usize)) -> Anchor { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors[ai] +} + +/// The anchors selected: those direct-selected, and every anchor of a path selected whole. +fn selected_anchors(s: &Session) -> usize { + let st = s.doc().unwrap(); + let count = |id: &NodeId| st.doc.node(*id).and_then(|n| n.path_data()).map_or(0, |pd| pd.anchors().count()); + st.selection.objects.iter().map(|id| st.selection.partial(*id).map_or_else(|| count(id), |set| set.len())).sum() +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn row(name: &str, ms: f64) { + println!(" {name:<58} {ms:>10.3} ms"); +} + +/// Select `count` anchors: the first ones of the first path, or the first anchors of as many +/// paths as needed. +fn select_count(s: &mut Session, shape: &Shape, count: usize) { + exec(s, "select.none", json!({})); + let mut left = count; + for id in &shape.ids { + if left == 0 { + break; + } + let n = s.doc().unwrap().doc.node(*id).unwrap().path_data().unwrap().subpaths[0].anchors.len(); + let take = left.min(n); + let refs: Vec<[usize; 2]> = (0..take).map(|i| [0, i]).collect(); + exec(s, "select.anchors", json!({"id": id.0, "anchors": refs, "mode": "add"})); + left -= take; + } +} + +fn bench(shape: Shape) { + println!("{}", shape.name); + let view = ViewInfo { smart_guides: false, ..ViewInfo::default() }; + let mut s = Session::new(); + s.add_document(shape.doc.clone(), None); + s.select_tool("directSelection", view).unwrap(); + let a = anchor_at(&s, shape.anchor).p; + let none = Mods::default(); + { + let doc = s.doc().unwrap().doc.clone(); + let opt = vectorcraft_doc::hit::HitOptions { tol: 3.0, clip_outlines: true, ..Default::default() }; + row("hit test at an anchor", time(|| drop(black_box(vectorcraft_doc::hit::hit_test(&doc, a, opt))))); + row("hit test at a segment", time(|| drop(black_box(vectorcraft_doc::hit::hit_test(&doc, shape.segment, opt))))); + row("hit test beside the path", time(|| drop(black_box(vectorcraft_doc::hit::hit_test(&doc, shape.segment + Vec2::new(0.0, 40.0), opt))))); + } + + // Hover with nothing selected, then with one anchor of the path selected. + for selected in [false, true] { + if selected { + exec(&mut s, "select.anchors", json!({"id": shape.anchor.0.0, "anchors": [[0, shape.anchor.1]], "mode": "set"})); + } + let what = if selected { "one anchor selected" } else { "nothing selected" }; + row(&format!("hover over an anchor, {what}"), time(|| drop(s.pointer(&ev(PointerKind::Move, a), view).unwrap()))); + row(&format!("cursor over an anchor, {what}"), time(|| _ = black_box(s.cursor(a, none, view)))); + row(&format!("hover over a segment, {what}"), time(|| drop(s.pointer(&ev(PointerKind::Move, shape.segment), view).unwrap()))); + row(&format!("cursor over a segment, {what}"), time(|| _ = black_box(s.cursor(shape.segment, none, view)))); + } + + // A click that selects one anchor. + exec(&mut s, "select.none", json!({})); + row( + "click that selects one anchor", + time(|| { + s.pointer(&ev(PointerKind::Down, a), view).unwrap(); + s.pointer(&ev(PointerKind::Up, a), view).unwrap(); + }), + ); + assert_eq!(selected_anchors(&s), 1, "the click selects one anchor"); + + // One anchor dragged: each pointer move, then the release. + let undo = undo_len(&s); + s.pointer(&ev(PointerKind::Down, a), view).unwrap(); + s.pointer(&ev(PointerKind::Drag, a + Vec2::new(0.0, 20.0)), view).unwrap(); + let mut y = 20.0; + row( + "anchor drag, one pointer move", + time(|| { + y += 1.0; + s.pointer(&ev(PointerKind::Drag, a + Vec2::new(0.0, y)), view).unwrap(); + }), + ); + let t = Instant::now(); + s.pointer(&ev(PointerKind::Up, a + Vec2::new(0.0, y)), view).unwrap(); + row("anchor drag, release (commit)", t.elapsed().as_secs_f64() * 1000.0); + assert_eq!(undo_len(&s), undo + 1, "the anchor drag records one step"); + exec(&mut s, "edit.undo", json!({})); + + // The selected anchor's outgoing handle dragged. + exec(&mut s, "select.anchors", json!({"id": shape.anchor.0.0, "anchors": [[0, shape.anchor.1]], "mode": "set"})); + let h = anchor_at(&s, shape.anchor).h_out; + let undo = undo_len(&s); + s.pointer(&ev(PointerKind::Down, h), view).unwrap(); + s.pointer(&ev(PointerKind::Drag, h + Vec2::new(0.0, 20.0)), view).unwrap(); + let mut y = 20.0; + row( + "handle drag, one pointer move", + time(|| { + y += 1.0; + s.pointer(&ev(PointerKind::Drag, h + Vec2::new(0.0, y)), view).unwrap(); + }), + ); + let t = Instant::now(); + s.pointer(&ev(PointerKind::Up, h + Vec2::new(0.0, y)), view).unwrap(); + row("handle drag, release (commit)", t.elapsed().as_secs_f64() * 1000.0); + assert_eq!(undo_len(&s), undo + 1, "the handle drag records one step"); + assert_eq!(anchor_at(&s, shape.anchor).p, a, "the handle drag leaves the anchor in place"); + exec(&mut s, "edit.undo", json!({})); + + // A marquee over about 10,000 anchors. + let (m0, m1) = shape.marquee; + let ms = time(|| { + exec(&mut s, "select.none", json!({})); + s.pointer(&ev(PointerKind::Down, m0), view).unwrap(); + s.pointer(&ev(PointerKind::Drag, m0.lerp(m1, 0.5)), view).unwrap(); + s.pointer(&ev(PointerKind::Drag, m1), view).unwrap(); + s.pointer(&ev(PointerKind::Up, m1), view).unwrap(); + }); + row(&format!("marquee over {} anchors (press to release)", selected_anchors(&s)), ms); + + // Select All with Direct Selection. + row("Select All", time(|| exec(&mut s, "select.all", json!({})))); + row("tool overlays, everything selected", time(|| drop(s.overlays(view)))); + + // Nudges of 1, 1,000 and every anchor, and Undo and Redo of each. + let total: usize = shape.ids.iter().map(|id| s.doc().unwrap().doc.node(*id).unwrap().path_data().unwrap().subpaths[0].anchors.len()).sum(); + for count in [1, 1000, total] { + select_count(&mut s, &shape, count); + row(&format!("nudge of {count} anchors"), time(|| exec(&mut s, "path.moveAnchors", json!({"dx": 1, "dy": 0})))); + row(&format!("undo of a nudge of {count} anchors"), time(|| exec(&mut s, "edit.undo", json!({})))); + row(&format!("redo of a nudge of {count} anchors"), time(|| exec(&mut s, "edit.redo", json!({})))); + } + + // The render of the document after one anchor moves (the renderer warm). + exec(&mut s, "select.anchors", json!({"id": shape.anchor.0.0, "anchors": [[0, shape.anchor.1]], "mode": "set"})); + let opts = RenderOptions::default(); + let mut r = Renderer::new(); + let at = Affine::translate(Vec2::new(800.0 - a.x, 600.0 - a.y)); + r.render(&s.doc().unwrap().doc, 1600, 1200, at, &opts); + let ms = median( + (0..iters()) + .map(|_| { + exec(&mut s, "path.moveAnchors", json!({"dx": 0, "dy": 1})); + let doc = s.doc().unwrap().doc.clone(); + let t = Instant::now(); + drop(r.render(&doc, 1600, 1200, at, &opts)); + t.elapsed().as_secs_f64() * 1000.0 + }) + .collect(), + ); + row("render 1600x1200 around the anchor after it moves", ms); + let doc = s.doc().unwrap().doc.clone(); + row("render 1600x1200 around the anchor, nothing changed", time(|| drop(r.render(&doc, 1600, 1200, at, &opts)))); + + // Delete of one anchor, undone after each run. + let ms = median( + (0..iters()) + .map(|_| { + exec(&mut s, "select.anchors", json!({"id": shape.anchor.0.0, "anchors": [[0, shape.anchor.1]], "mode": "set"})); + let t = Instant::now(); + exec(&mut s, "edit.clear", json!({})); + let ms = t.elapsed().as_secs_f64() * 1000.0; + exec(&mut s, "edit.undo", json!({})); + ms + }) + .collect(), + ); + row("Delete of one anchor", ms); +} + +fn main() { + bench(one_outline()); + bench(many_paths()); + bench(long_open_path()); + bench(dashed_outline()); +} diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 285604af5..b1b9efd60 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -168,9 +168,9 @@ pub fn has_selection(s: &Session) -> std::result::Result<(), String> { let st = s.active().ok_or("no document open")?; if st.selection.is_empty() { Err("nothing selected".into()) } else { Ok(()) } } -/// Objects are selected, or the Artboard tool is chosen (Copy and Cut take its artboard). -pub fn has_selection_or_artboard_tool(s: &Session) -> std::result::Result<(), String> { - if s.tool_id() == "artboard" { has_doc(s) } else { has_selection(s) } +/// Objects or ruler guides are selected, or the Artboard tool is chosen (what Copy and Cut take). +pub fn has_selection_guides_or_artboard_tool(s: &Session) -> std::result::Result<(), String> { + if s.tool_id() == "artboard" { has_doc(s) } else { has_selection_or_guides(s) } } /// Objects or ruler guides are selected (what Delete and the arrow keys act on). pub fn has_selection_or_guides(s: &Session) -> std::result::Result<(), String> { @@ -484,9 +484,10 @@ pub fn matrix_param(p: &Value, key: &str) -> Option { } /// A colour from `"#rrggbb"`, a colour key (`"cmyk 0 100 100 0"`, `"lab 55 60 40"`), `[r,g,b]` (0..1), /// `{"c":..,"m":..,"y":..,"k":..}` (percent when any component is above 1, else 0..1), `{"gray":..}` or `{"l":..,"a":..,"b":..}` (CIE Lab: L 0..100, a and b about -/// −128..127). +/// −128..127). `None` for a hex color with an alpha part (`#rrggbbaa` or `#rgba`, with or without the `#`). pub fn color_value(v: &Value) -> Option { match v { + Value::String(s) if hex_with_alpha(s) => None, // A hex, or a colour key ("cmyk 0 100 100 0", as Recolor Artwork lists colours). Value::String(s) => Color::from_hex(s).or_else(|| vectorcraft_color::recolor::ColorKey::parse(s).map(|k| k.color())), Value::Array(a) if a.len() >= 3 => Some(Color::rgb(a[0].as_f64()? as f32, a[1].as_f64()? as f32, a[2].as_f64()? as f32)), @@ -512,6 +513,23 @@ pub fn color_value(v: &Value) -> Option { } } +/// Is `s` a hex color with an alpha part: eight or four hex digits, with or without the `#`? +/// [`Color::from_hex`] reads `#rrggbbaa` as `#rrggbb`, without the alpha. +pub(crate) fn hex_with_alpha(s: &str) -> bool { + let s = s.trim(); + let h = s.strip_prefix('#').unwrap_or(s); + matches!(h.len(), 4 | 8) && h.chars().all(|c| c.is_ascii_hexdigit()) +} + +/// The color `p` gives for `key`, read by [`color_value`]: `None` when `p` leaves it out or gives +/// null. Any other value that is not a color is an error: "bad color {v}". +pub(crate) fn color_param(p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key).filter(|v| !v.is_null()) { + None => Ok(None), + Some(v) => color_value(v).map(Some).ok_or_else(|| bad(cmd, format!("bad color {v}"))), + } +} + /// `v` as an object id: an error unless it is a non-negative integer. fn object_id(v: &Value) -> Result { v.as_u64().map(NodeId).ok_or_else(|| EngineError::Other(format!("object ids are non-negative integers, not {v}"))) @@ -611,6 +629,26 @@ pub(crate) fn unique_name(base: &str, taken: impl Fn(&str) -> bool) -> String { (2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) } +/// [`unique_name`] for names given one after another (an import): `next` keeps, per base (in +/// any case), the number after the last one tried, so a run of one name doesn't count up from 2 +/// again for each copy. Names only become taken meanwhile, so the result is the same. +pub(crate) fn unique_name_from(base: &str, next: &mut std::collections::HashMap, taken: impl Fn(&str) -> bool) -> String { + if !taken(base) { + return base.to_string(); + } + let i = next.entry(base.to_lowercase()).or_insert(2); + loop { + let n = format!("{base} {i}"); + *i += 1; + if !taken(&n) { + return n; + } + } +} + +/// The most presets one presets file imports: each is looked up among the saved ones. +pub(crate) const MAX_IMPORTED_PRESETS: usize = 1000; + pub(crate) use edit::{settle_interaction, typing_in_progress}; pub(crate) use object::bounds_rewrite; pub(crate) use vectorcraft_doc::range::parse_range; diff --git a/crates/engine/src/cmd/addons.rs b/crates/engine/src/cmd/addons.rs index 21d73cc4f..ac9597cbd 100644 --- a/crates/engine/src/cmd/addons.rs +++ b/crates/engine/src/cmd/addons.rs @@ -7,6 +7,8 @@ mod color; mod document; mod pathtools; mod pen; +mod select; +mod typetools; use super::CommandSpec; @@ -18,5 +20,7 @@ pub fn specs() -> Vec { v.extend(document::specs()); v.extend(pathtools::specs()); v.extend(pen::specs()); + v.extend(select::specs()); + v.extend(typetools::specs()); v } diff --git a/crates/engine/src/cmd/addons/color.rs b/crates/engine/src/cmd/addons/color.rs index d1ed6c84a..ac8947cb4 100644 --- a/crates/engine/src/cmd/addons/color.rs +++ b/crates/engine/src/cmd/addons/color.rs @@ -59,7 +59,7 @@ pub fn specs() -> Vec { "Find Nearest Swatch", ["Object", "Plug-ins", "Color Effects"], None, - "{library?: a swatch library's id or name (swatch.library.list: the built-in ones, and the colour books and libraries the user saved or loaded) or \"document\" (the document's swatches; the default), color?: colour (look this colour up; default: the selection's), fill?: true, stroke?: true (the selected objects' fills and strokes, their type's and those of the objects inside them: solid colours and gradient stops, once each, the first 100), count?: 1..50 (5), ids?: [id…] (default: the selection)} list the library's colour swatches closest to each colour, closest first, by CIEDE2000 difference (ΔE; under 1 hardly tells apart, about 2 to 3 close). Tint swatches, None and Registration aren't matched → {library, colors: [{values, hex, matches: [{name, values, hex, spot, deltaE}]}]}", + "{library?: a swatch library's id or name (swatch.library.list: the built-in ones, and the colour books and libraries the user saved or loaded) or \"document\" (the document's swatches; the default), color?: colour (look this colour up, #rrggbbaa and #rgba refused; default: the selection's), fill?: true, stroke?: true (the selected objects' fills and strokes, their type's and those of the objects inside them: solid colours and gradient stops, once each, the first 100), count?: 1..50 (5), ids?: [id…] (default: the selection)} list the library's colour swatches closest to each colour, closest first, by CIEDE2000 difference (ΔE; under 1 hardly tells apart, about 2 to 3 close). Tint swatches, None and Registration aren't matched → {library, colors: [{values, hex, matches: [{name, values, hex, spot, deltaE}]}]}", has_doc, nearest::find ), diff --git a/crates/engine/src/cmd/addons/document.rs b/crates/engine/src/cmd/addons/document.rs index 30548b80a..52f7f3168 100644 --- a/crates/engine/src/cmd/addons/document.rs +++ b/crates/engine/src/cmd/addons/document.rs @@ -1,9 +1,11 @@ //! Document Tools (`addon.document.*`), a built-in add-on: Save a Timestamped Copy writes a copy of //! the document named with the date and time through File › Save a Copy's save; New Symbols from //! Layers makes a symbol of each layer's art as New Symbol does; Export All Open Documents writes a -//! copy of every open document to one folder as Save a Copy does (#4920). Its submenu (Object › Plug-ins › -//! Document Tools) is the UI's. +//! copy of every open document to one folder as Save a Copy does (#4920); Batch Trace Folder traces +//! each image of a folder and saves the results to another ([`batch_trace`], #4921). Its submenu +//! (Object › Plug-ins › Document Tools) is the UI's. +mod batch_trace; #[cfg(test)] mod tests; @@ -14,7 +16,7 @@ use vectorcraft_doc::{Node, NodeId, NodeKind, SymbolRegistration}; use super::super::brushsym::{instance_in_place, normalize, place_xf, store_symbol}; use super::super::fileio::{self, SaveMode}; -use super::super::{CommandSpec, bad, checked_ids_param, clock_date, cmd, date_param, has_doc, str_param, switch_or}; +use super::super::{CommandSpec, always, bad, checked_ids_param, clock_date, cmd, date_param, has_doc, str_param, switch_or}; use crate::{Result, Session}; /// The most numbers (`-2`, `-3`…) tried after a timestamped name that is taken. @@ -40,6 +42,15 @@ pub fn specs() -> Vec { has_doc, export_all_open ), + cmd!( + "addon.document.batchTraceFolder", + "Batch Trace Folder", + ["Object", "Plug-ins", "Document Tools"], + None, + "{folder, destinationFolder, preset?: name (imageTrace.presets; default: Default), expand?: false (true: trace and expand, as Make and Expand), format?: vectorcraft (default)|svg|pdf|ai|eps, includeSubdirs?: false} open each image in folder (png, jpg, tif, bmp, gif, webp, psd), select it, trace it with the preset, save it into destinationFolder as . (-2, -3… added when that name is taken, so no file is replaced) and close it again, whether it traced or not; the document that was active stays active. Caps: 500 images, 8 folder levels; a folder with more than 1,000 entries is refused. Not on the web (no file system) → {traced: [{path, output}], failed: [{path, error}], format} (#4921)", + always, + batch_trace::batch_trace_folder + ), cmd!( "addon.document.symbolsFromLayers", "New Symbols from Layers", diff --git a/crates/engine/src/cmd/addons/document/batch_trace.rs b/crates/engine/src/cmd/addons/document/batch_trace.rs new file mode 100644 index 000000000..0340fca18 --- /dev/null +++ b/crates/engine/src/cmd/addons/document/batch_trace.rs @@ -0,0 +1,127 @@ +//! Batch Trace Folder (`addon.document.batchTraceFolder`, #4921): each image in a folder is opened, +//! traced with a preset (and expanded when asked), saved into a destination folder under its own +//! name, and closed again, whether it traced or not. + +use serde_json::Value; +#[cfg(not(target_arch = "wasm32"))] +use serde_json::json; + +#[cfg(not(target_arch = "wasm32"))] +use super::super::super::fileio; +use super::super::super::{Result, Session, bad}; +#[cfg(not(target_arch = "wasm32"))] +use super::super::super::{bool_or, str_param}; + +/// The command id. +pub(super) const ID: &str = "addon.document.batchTraceFolder"; + +#[cfg(target_arch = "wasm32")] +pub(super) fn batch_trace_folder(_: &mut Session, _: &Value) -> Result { + Err(bad(ID, "no file system here")) +} + +#[cfg(not(target_arch = "wasm32"))] +/// The image files a batch traces, by extension. +const IMAGE_EXTS: [&str; 10] = ["png", "jpg", "jpeg", "tif", "tiff", "bmp", "gif", "webp", "psd", "psb"]; +#[cfg(not(target_arch = "wasm32"))] +/// Caps on a batch (folders and MCP payloads are untrusted). +pub(super) const MAX_FILES: usize = 500; +#[cfg(not(target_arch = "wasm32"))] +const MAX_DEPTH: usize = 8; +#[cfg(not(target_arch = "wasm32"))] +const FORMATS: [&str; 5] = ["vectorcraft", "svg", "pdf", "ai", "eps"]; + +#[cfg(not(target_arch = "wasm32"))] +/// The image files in `folder` (and its subfolders with `deep`), sorted. +fn images(folder: &str, deep: bool, depth: usize, out: &mut Vec) -> Result<()> { + if depth > MAX_DEPTH { + return Err(bad(ID, format!("folders deeper than {MAX_DEPTH} levels"))); + } + let max = MAX_FILES.saturating_mul(2); + let (entries, truncated) = fileio::list_dir(folder, max)?; + // The listing is cut before it is sorted, so a cut one could skip any image: refuse it. + if truncated { + return Err(bad(ID, format!("{folder}: more than {max} files and folders"))); + } + let mut subdirs = vec![]; + for e in entries { + let name = e.get("name").and_then(Value::as_str).unwrap_or(""); + if name.is_empty() || name.starts_with('.') { + continue; + } + let path = std::path::Path::new(folder).join(name).to_string_lossy().into_owned(); + match e.get("kind").and_then(Value::as_str) { + Some("file") if IMAGE_EXTS.contains(&fileio::extension(&path).as_str()) => out.push(path), + Some("folder") if deep => subdirs.push(path), + _ => {} + } + if out.len() > MAX_FILES { + return Err(bad(ID, format!("more than {MAX_FILES} images"))); + } + } + for d in subdirs { + images(&d, deep, depth + 1, out)?; + } + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn batch_trace_folder(s: &mut Session, p: &Value) -> Result { + let folder = str_param(p, "folder").map(fileio::absolute_path).ok_or_else(|| bad(ID, "missing `folder`"))?; + let dest = str_param(p, "destinationFolder").map(fileio::absolute_path).ok_or_else(|| bad(ID, "missing `destinationFolder`"))?; + let format = str_param(p, "format").unwrap_or("vectorcraft"); + if !FORMATS.contains(&format) { + return Err(bad(ID, format!("`format` must be one of {}", FORMATS.join(", ")))); + } + let preset = str_param(p, "preset").unwrap_or("Default").to_string(); + let presets = s.execute("imageTrace.presets", &json!({}))?; + if !presets["presets"].as_array().into_iter().flatten().any(|q| q["name"].as_str().is_some_and(|n| n.eq_ignore_ascii_case(&preset))) { + return Err(bad(ID, format!("no tracing preset `{preset}` (see imageTrace.presets)"))); + } + let trace = if bool_or(p, "expand", false) { "imageTrace.makeAndExpand" } else { "imageTrace.make" }; + fileio::may_read(&folder)?; + fileio::may_write(&dest)?; + let mut files = vec![]; + images(&folder, bool_or(p, "includeSubdirs", false), 0, &mut files)?; + files.sort(); + if files.is_empty() { + return Err(bad(ID, "no images in the folder")); + } + std::fs::create_dir_all(&dest).map_err(|e| bad(ID, format!("{dest}: {e}")))?; + let ext = fileio::save_format(Some(format), None).map_err(|e| bad(ID, e))?.extensions.first().copied().unwrap_or(format); + let active = s.active_index(); + let (mut traced, mut failed, mut taken) = (vec![], vec![], Vec::::new()); + for path in &files { + let opened_before = s.documents().len(); + let one = |s: &mut Session| -> Result { + s.execute("document.open", &json!({ "path": path }))?; + s.execute("select.all", &json!({}))?; + s.execute(trace, &json!({ "preset": preset }))?; + let stem = fileio::file_stem(path); + // Names this batch already took count as taken, as do files on disk. + let free = super::free_name(&dest, &stem, ext, |o| taken.iter().any(|t| t == o) || std::path::Path::new(o).exists()) + .ok_or_else(|| bad(ID, "every numbered name for the result is taken"))?; + s.execute("document.save", &json!({ "path": free, "format": format }))?; + Ok(free) + }; + let result = one(s); + // The image's document closes whatever happened, so a failure leaves no tab behind. + if s.documents().len() > opened_before + && let Some(i) = s.active_index() + { + // A just-opened image has no work to lose; if closing fails, one extra tab stays open. + let _ = s.execute("file.close", &json!({ "index": i })); + } + match result { + Ok(out) => { + taken.push(out.clone()); + traced.push(json!({ "path": path, "output": out })); + } + Err(e) => failed.push(json!({ "path": path, "error": e.to_string() })), + } + } + if let Some(i) = active.filter(|i| *i < s.documents().len()) { + s.set_active(i); + } + Ok(json!({ "traced": traced, "failed": failed, "format": format })) +} diff --git a/crates/engine/src/cmd/addons/document/tests.rs b/crates/engine/src/cmd/addons/document/tests.rs index 562a389e8..a1bc5afff 100644 --- a/crates/engine/src/cmd/addons/document/tests.rs +++ b/crates/engine/src/cmd/addons/document/tests.rs @@ -1,5 +1,5 @@ //! Document Tools' commands: the timestamped copy's name and place with the document left as it -//! was, symbols made of layers, and bad params refused. +//! was, symbols made of layers, a folder of images traced, and bad params refused. use std::path::{Path, PathBuf}; @@ -276,3 +276,77 @@ fn export_all_open_documents_keeps_every_page_and_artboard() { assert!(w > 19.0 && w < 30.0, "the SVG shows the art (the square on the second artboard), not the first artboard: viewBox {view}"); let _ = std::fs::remove_dir_all(&dir); } + +/// A 24×24 white PNG with a dark (or red) square in it. +#[cfg(not(target_arch = "wasm32"))] +fn png(path: &Path, dark: bool) { + let mut img = image::RgbaImage::from_pixel(24, 24, image::Rgba([255, 255, 255, 255])); + for y in 6..18 { + for x in 6..18 { + img.put_pixel(x, y, if dark { image::Rgba([0, 0, 0, 255]) } else { image::Rgba([200, 30, 30, 255]) }); + } + } + img.save(path).unwrap(); +} + +/// #4921: each image of the folder is traced and saved under its own name; a file that isn't an +/// image is left alone, one that can't be read is reported, and every document the batch opened +/// is closed again, the active one staying active. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn batch_trace_folder_traces_and_saves_each_image() { + const C: &str = "addon.document.batchTraceFolder"; + let dir = folder("batch-trace"); + let (src, out) = (dir.join("in"), dir.join("out")); + std::fs::create_dir_all(&src).unwrap(); + png(&src.join("logo.png"), true); + png(&src.join("mark.png"), false); + std::fs::write(src.join("notes.txt"), "not an image").unwrap(); + std::fs::write(src.join("broken.png"), b"not a png").unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let docs = s.documents().len(); + let r = s.execute(C, &json!({"folder": src, "destinationFolder": out, "format": "svg", "expand": true})).unwrap(); + assert_eq!(r["traced"].as_array().unwrap().len(), 2, "{r}"); + assert_eq!(r["failed"].as_array().unwrap().len(), 1, "{r}"); + assert!(r["failed"][0]["path"].as_str().unwrap().ends_with("broken.png")); + let mut names: Vec = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + assert_eq!(names, ["logo.svg", "mark.svg"]); + let svg = std::fs::read_to_string(out.join("logo.svg")).unwrap(); + assert!(svg.contains(" Vec { + vec![cmd!( + "addon.select.byArtboards", + "Select by Artboards", + ["Object", "Plug-ins", "Selection Tools"], + None, + "{artboards: [index…] (0-based, at least one), relation?: \"overlap\" (default: on at least one of them, as select.allOnArtboard takes it: the bounds overlap the artboard or the centre is on it) | \"inside\" (the bounds lie wholly within one of them, edges included) | \"outside\" (on none of them)} select those objects among the ones Select All takes (in isolation mode, the isolated container's); hidden and locked objects, objects on hidden, locked or template layers and art held by a locked artboard are left out. Bounds are geometric, a symbol instance's measured by its art → {count}", + has_doc, + by_artboards + )] +} + +fn by_artboards(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.select.byArtboards"; + let relation = text_param(p, "relation", C)?.unwrap_or("overlap"); + if !RELATIONS.contains(&relation) { + return Err(bad(C, "`relation` must be overlap, inside or outside")); + } + let listed = p.get("artboards").ok_or_else(|| bad(C, "missing `artboards`"))?; + let indices = point_indices(listed).ok_or_else(|| bad(C, "`artboards` must be a list of artboard indices"))?; + if indices.is_empty() { + return Err(bad(C, "choose at least one artboard")); + } + let st = s.doc()?; + let rects = + indices.iter().map(|i| st.doc.artboards.get(*i).map(|a| a.rect)).collect::>>().ok_or_else(|| bad(C, "no such artboard"))?; + let symbols = st.doc.symbol_boxes(); + let ids: Vec = selectable(&st.doc, st.isolation) + .into_iter() + .filter(|id| { + let Some(n) = st.doc.node(*id) else { return false }; + match relation { + "inside" => n.geometric_bounds_with(&symbols).is_some_and(|b| { + rects.iter().any(|r| { + vectorcraft_doc::artboard_holds(*r, Point::new(b.x0, b.y0)) && vectorcraft_doc::artboard_holds(*r, Point::new(b.x1, b.y1)) + }) + }), + "outside" => !rects.iter().any(|r| on_artboard(n, &symbols, *r)), + _ => rects.iter().any(|r| on_artboard(n, &symbols, *r)), + } + }) + .collect(); + s.select(|_, sel| sel.set(ids))?; + Ok(json!({ "count": s.doc()?.selection.objects.len() })) +} diff --git a/crates/engine/src/cmd/addons/select/tests.rs b/crates/engine/src/cmd/addons/select/tests.rs new file mode 100644 index 000000000..bea05ff9e --- /dev/null +++ b/crates/engine/src/cmd/addons/select/tests.rs @@ -0,0 +1,128 @@ +//! Select by Artboards. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; + +use crate::Session; + +const C: &str = "addon.select.byArtboards"; + +/// A document with three 100 × 100 artboards in a row, 50 pt apart: 0 at x 0, 1 at x 150, 2 at x 300. +fn three_boards() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + for x in [150, 300] { + s.execute("artboard.new", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap(); + } + s +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap()) +} + +fn pick(s: &mut Session, p: Value) -> Vec { + s.execute("select.none", &json!({})).unwrap(); + let r = s.execute(C, &p).unwrap(); + let mut ids = s.doc().unwrap().selection.objects.clone(); + assert_eq!(r["count"], json!(ids.len())); + ids.sort(); + ids +} + +#[test] +fn selects_by_overlap_inside_and_outside_several_artboards() { + let mut s = three_boards(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); // inside 0 + let ab = rect(&mut s, 90.0, 40.0, 80.0, 20.0); // across 0 and 1 + let b = rect(&mut s, 160.0, 10.0, 20.0, 20.0); // inside 1 + let c = rect(&mut s, 310.0, 10.0, 20.0, 20.0); // inside 2 + let off = rect(&mut s, 0.0, 200.0, 20.0, 20.0); // on no board + let edge = rect(&mut s, 0.0, 0.0, 100.0, 100.0); // flush with board 0 + let want = |mut v: Vec| { + v.sort(); + v + }; + assert_eq!(pick(&mut s, json!({"artboards": [0, 1]})), want(vec![a, ab, b, edge])); + assert_eq!(pick(&mut s, json!({"artboards": [0, 1], "relation": "overlap"})), want(vec![a, ab, b, edge])); + // Wholly within one board, edges included; straddling two boards is not inside either. + assert_eq!(pick(&mut s, json!({"artboards": [0, 1], "relation": "inside"})), want(vec![a, b, edge])); + assert_eq!(pick(&mut s, json!({"artboards": [0, 1], "relation": "outside"})), want(vec![c, off])); + assert_eq!(pick(&mut s, json!({"artboards": [2], "relation": "outside"})), want(vec![a, ab, b, off, edge])); + // One board does what All on Active Artboard does. + for i in 0..3u64 { + s.execute("select.allOnArtboard", &json!({"artboard": i})).unwrap(); + let mut on = s.doc().unwrap().selection.objects.clone(); + on.sort(); + assert_eq!(pick(&mut s, json!({"artboards": [i]})), on, "board {i}"); + } + // A board listed twice counts once. + assert_eq!(pick(&mut s, json!({"artboards": [2, 2]})), vec![c]); +} + +#[test] +fn hidden_locked_and_isolated_art_follow_select_all() { + let mut s = three_boards(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + let hidden = rect(&mut s, 40.0, 10.0, 20.0, 20.0); + let locked = rect(&mut s, 70.0, 10.0, 20.0, 20.0); + let far = rect(&mut s, 0.0, 300.0, 20.0, 20.0); + s.execute("object.setProps", &json!({"ids": [hidden.0], "visible": false})).unwrap(); + s.execute("object.setProps", &json!({"ids": [locked.0], "locked": true})).unwrap(); + assert_eq!(pick(&mut s, json!({"artboards": [0]})), vec![a]); + assert_eq!(pick(&mut s, json!({"artboards": [0], "relation": "outside"})), vec![far]); + // Art held by a locked artboard is left out. + s.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap(); + assert_eq!(pick(&mut s, json!({"artboards": [0]})), vec![]); + s.execute("artboard.setProps", &json!({"index": 0, "locked": false})).unwrap(); + // In isolation mode only the isolated group's objects are looked at. + let b = rect(&mut s, 20.0, 50.0, 10.0, 10.0); + s.execute("select.none", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.isolate", &json!({"id": g})).unwrap(); + let mut both = vec![a, b]; + both.sort(); + assert_eq!(pick(&mut s, json!({"artboards": [0], "relation": "inside"})), both); + assert_eq!(pick(&mut s, json!({"artboards": [1]})), vec![]); +} + +#[test] +fn bad_params_are_errors_and_leave_the_selection() { + let mut s = three_boards(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + for p in [ + json!({}), + json!({"artboards": []}), + json!({"artboards": [3]}), + json!({"artboards": [-1]}), + json!({"artboards": [0.5]}), + json!({"artboards": "0"}), + json!({"artboards": [0], "relation": "near"}), + json!({"artboards": [0], "relation": 1}), + json!({"artboards": [0], "relatoin": "inside"}), + ] { + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + assert!(s.execute(C, &p).is_err(), "{p}"); + assert_eq!(s.doc().unwrap().selection.objects, vec![a], "{p}"); + } +} + +#[test] +fn a_symbol_instance_is_measured_by_its_art() { + // The symbol's art, (100, 60) to (140, 80), drawn 50 pt up and left: (50, 10) to (90, 30), on + // the 200 × 120 artboard, while the 20 × 20 square around the instance's origin lies off it. + let svg = r##""##; + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "far.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap(); + let mut instance = None; + s.doc().unwrap().doc.walk(|n| { + if matches!(n.kind, vectorcraft_doc::NodeKind::SymbolInstance { .. }) { + instance = Some(n.id); + } + }); + let instance = instance.unwrap(); + assert_eq!(pick(&mut s, json!({"artboards": [0]})), vec![instance]); + assert_eq!(pick(&mut s, json!({"artboards": [0], "relation": "inside"})), vec![instance]); + assert_eq!(pick(&mut s, json!({"artboards": [0], "relation": "outside"})), vec![]); +} diff --git a/crates/engine/src/cmd/addons/typetools.rs b/crates/engine/src/cmd/addons/typetools.rs new file mode 100644 index 000000000..a478b8c00 --- /dev/null +++ b/crates/engine/src/cmd/addons/typetools.rs @@ -0,0 +1,189 @@ +//! Type Tools (`addon.type.*`), a built-in add-on: Text Tweaker gives one character attribute to +//! the selected characters letter by letter, from a start value to an end value along a shape +//! (straight, eased, a bow, a spike, or at random), while the text stays live. Values are set as +//! the Character panel sets them (`text.setRangeStyle`), in its units and ranges +//! (). Its submenu +//! (Object › Plug-ins › Type Tools) and dialog are the UI's. + +#[cfg(test)] +mod tests; + +use serde_json::{Value, json}; +use vectorcraft_color::recolor::Rng; +use vectorcraft_doc::{NodeId, TextRun}; +use vectorcraft_text::edit; + +use super::super::menucmds::whole_or; +use super::super::object::choice; +use super::super::textedit::{CharChange, text_mut, text_ref, type_ranges}; +use super::super::typecmd::refresh_bounds; +use super::super::{CommandSpec, bad, cmd, has_selection, number_or, switch_or}; +use crate::{EngineError, Result, Session}; + +/// An attribute Text Tweaker sets: its `text.setRangeStyle` param, the values it takes and its +/// default start and end. +type Attribute = (&'static str, (f64, f64), (f64, f64)); + +/// The attributes Text Tweaker sets. +pub const ATTRIBUTES: [Attribute; 8] = [ + ("size", (0.1, 1296.0), (12.0, 36.0)), + ("baselineShift", (-1296.0, 1296.0), (0.0, 12.0)), + ("rotation", (-360.0, 360.0), (0.0, 30.0)), + ("hScale", (1.0, 10000.0), (100.0, 150.0)), + ("vScale", (1.0, 10000.0), (100.0, 150.0)), + ("tracking", (-1000.0, 10000.0), (0.0, 200.0)), + ("leading", (0.1, 5000.0), (12.0, 36.0)), + ("strokeWidth", (0.0, 1000.0), (0.0, 2.0)), +]; +/// The shapes the values follow from the first character to the last. +pub const SHAPES: [&str; 5] = ["straight", "ease", "bow", "spike", "random"]; +/// The most characters one run changes. +const MAX_CHARS: usize = 100_000; + +pub fn specs() -> Vec { + vec![cmd!( + "addon.type.textTweaker", + "Text Tweaker", + ["Object", "Plug-ins", "Type Tools"], + None, + "{attribute?: \"size\" (pt 0.1..1296) | \"baselineShift\" (pt −1296..1296) | \"rotation\" (° −360..360) | \"hScale\" (% 1..10000) | \"vScale\" (% 1..10000) | \"tracking\" (1/1000 em −1000..10000) | \"leading\" (pt 0.1..5000) | \"strokeWidth\" (pt 0..1000) (\"size\"), from?: the first character's value, to?: the last character's value (each in the attribute's units and range; defaults: size 12 → 36, baselineShift 0 → 12, rotation 0 → 30, hScale and vScale 100 → 150, tracking 0 → 200, leading 12 → 36, strokeWidth 0 → 2), shape?: \"straight\" (an even ramp) | \"ease\" (slow at both ends) | \"bow\" (from at both ends, rising smoothly to `to` in the middle) | \"spike\" (from at both ends, straight up to `to` in the middle) | \"random\" (each character anywhere from `from` to `to`) (\"straight\"), seed?: whole number (0; random: the same seed and text give the same values), skipSpaces?: true (spaces and line breaks keep their values and don't count), id?, start?: byte, end?: byte (range endpoints inside a character round down to its start), ids?: [id…] (instead of id)} give the characters one character attribute letter by letter, from `from` to `to` along the shape, as the Character panel sets it, as one undo step; the text stays live and editable. The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: all its text), else every selected type object; each object gets its own ramp. Stroke weight only shows on characters with a stroke → {count (characters changed), ids}", + has_selection, + text_tweaker + )] +} + +/// Where along `shape` (0 at `from`, 1 at `to`) character `i` of `n` lands; `random` for the +/// random shape. +fn amount(shape: &str, i: usize, n: usize, random: f64) -> f64 { + let t = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 }; + match shape { + "ease" => t * t * (3.0 - 2.0 * t), + "bow" => (std::f64::consts::PI * t).sin(), + "spike" => 1.0 - (2.0 * t - 1.0).abs(), + "random" => random, + _ => t, + } +} + +/// A random number from 0 to 1. +fn unit(rng: &mut Rng) -> f64 { + (rng.next_u64() >> 11) as f64 / (1u64 << 53) as f64 +} + +fn text_tweaker(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.type.textTweaker"; + let attribute = choice(p, C, "attribute", &ATTRIBUTES.map(|a| a.0))?.unwrap_or("size"); + let Some(&(key, (lo, hi), (from0, to0))) = ATTRIBUTES.iter().find(|a| a.0 == attribute) else { + return Err(bad(C, format!("unknown attribute `{attribute}`"))); + }; + let mut ends = [0.0; 2]; + for (end, (k, default)) in ends.iter_mut().zip([("from", from0), ("to", to0)]) { + let v = number_or(p, k, default, C)?; + if !(lo..=hi).contains(&v) { + return Err(bad(C, format!("`{k}` must be from {lo} to {hi} for {key}"))); + } + *end = v; + } + let [from, to] = ends; + let shape = choice(p, C, "shape", &SHAPES)?.unwrap_or("straight"); + let mut rng = Rng::new(whole_or(p, "seed", 0, C)?); + let skip = switch_or(p, "skipSpaces", true, C)?; + let setup = s.doc()?.doc.setup.clone(); + // At a caret, all of the object's text. + let ranges = type_ranges(s, p, C, |text, _| Some((0, text.len())))?; + // Each object's changed characters: (byte, its change), worked out before the edit. + let mut plans: Vec<(NodeId, Vec<(usize, CharChange)>)> = vec![]; + let (mut seen, mut count) = (0, 0); + for (id, a, b, _) in ranges { + let t = text_ref(s, id)?; + let text = t.plain_text(); + let (a, b) = (edit::floor_char(&text, a), edit::floor_char(&text, b)); + let chars: Vec = + text.char_indices().filter(|&(i, c)| (a..b).contains(&i) && !(skip && (c.is_whitespace() || c.is_control()))).map(|(i, _)| i).collect(); + seen += chars.len(); + if seen > MAX_CHARS { + return Err(bad(C, format!("too many characters: at most {MAX_CHARS}"))); + } + // Each run's end byte, to find the style a character has now. + let ends: Vec = t + .runs + .iter() + .scan(0, |off, r| { + *off += r.text.len(); + Some(*off) + }) + .collect(); + let n = chars.len(); + let mut changes = Vec::with_capacity(n); + let mut run = 0; + for (i, at) in chars.into_iter().enumerate() { + let random = if shape == "random" { unit(&mut rng) } else { 0.0 }; + let value = from + (to - from) * amount(shape, i, n, random); + let mut change = CharChange::parse(&json!({ key: value }), C, &setup)?; + change.localize(&t, C)?; + while ends.get(run).is_some_and(|&e| e <= at) { + run += 1; + } + // A character that already has the value is left out, so a run that changes nothing + // makes no step. + if let Some(r) = t.runs.get(run) { + let mut style = r.style.clone(); + change.apply(&mut style); + if style == r.style { + continue; + } + } + changes.push((at, change)); + } + count += changes.len(); + if !changes.is_empty() { + plans.push((id, changes)); + } + } + if plans.is_empty() { + return Ok(json!({ "count": 0, "ids": [] })); + } + let ids: Vec = plans.iter().map(|(id, _)| id.0).collect(); + s.edit("Text Tweaker", |d, _| { + for (id, changes) in &plans { + let t = text_mut(d, *id).ok_or(EngineError::NoNode(*id))?; + t.runs = tweak(std::mem::take(&mut t.runs), changes); + refresh_bounds(t); + } + Ok(()) + })?; + Ok(json!({ "count": count, "ids": ids })) +} + +/// `runs` with each character at a byte of `changes` (in order) changed: its run split into +/// characters, the changes applied, and runs that came out alike merged again. +fn tweak(runs: Vec, changes: &[(usize, CharChange)]) -> Vec { + let mut out = Vec::with_capacity(runs.len() + changes.len() * 2); + let mut next = changes.iter().peekable(); + let mut off = 0; + for r in runs { + let end = off + r.text.len(); + if r.inline.is_some() || next.peek().is_none_or(|(at, _)| *at >= end) { + // An inline graphic is one character: its style changes whole. + if let Some((_, change)) = next.next_if(|(at, _)| (off..end).contains(at)) { + let mut r = r; + change.apply(&mut r.style); + out.push(r); + } else { + out.push(r); + } + off = end; + continue; + } + for (i, c) in r.text.char_indices() { + let mut piece = TextRun::new(c.to_string(), r.style.clone()); + if let Some((_, change)) = next.next_if(|(at, _)| *at == off + i) { + change.apply(&mut piece.style); + } + out.push(piece); + } + off = end; + } + edit::normalize(&mut out); + out +} diff --git a/crates/engine/src/cmd/addons/typetools/tests.rs b/crates/engine/src/cmd/addons/typetools/tests.rs new file mode 100644 index 000000000..7f05cb823 --- /dev/null +++ b/crates/engine/src/cmd/addons/typetools/tests.rs @@ -0,0 +1,223 @@ +//! Text Tweaker: the values each shape gives each character, what it skips and changes, one undo +//! step, and bad params refused without a change. + +use serde_json::json; +use vectorcraft_doc::{CharStyle, NodeId, NodeKind, TextObject}; + +use super::amount; +use crate::Session; + +const C: &str = "addon.type.textTweaker"; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + s +} + +fn text(s: &mut Session, words: &str) -> NodeId { + NodeId(s.execute("text.create", &json!({"x": 40, "y": 100, "text": words, "size": 20})).unwrap()["id"].as_u64().unwrap()) +} + +fn select(s: &mut Session, ids: &[NodeId]) { + s.select(|_, sel| sel.set(ids.iter().copied())).unwrap(); +} + +fn obj(s: &Session, id: NodeId) -> TextObject { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + } +} + +/// Each character of `id` with its style. +fn styles(s: &Session, id: NodeId) -> Vec<(char, CharStyle)> { + obj(s, id).runs.iter().flat_map(|r| r.text.chars().map(|c| (c, r.style.clone())).collect::>()).collect() +} + +fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn near(a: &[f64], b: &[f64]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9) +} + +#[test] +fn the_shapes_run_from_the_start_value_to_the_end_value() { + let at = |shape, n| (0..n).map(|i| amount(shape, i, n, 0.5)).collect::>(); + assert!(near(&at("straight", 5), &[0.0, 0.25, 0.5, 0.75, 1.0])); + assert!(near(&at("ease", 5), &[0.0, 0.15625, 0.5, 0.84375, 1.0]), "slow at both ends"); + let bow = at("bow", 5); + assert!(near(&[bow[0], bow[2], bow[4]], &[0.0, 1.0, 0.0]) && (bow[1] - 0.5f64.sqrt()).abs() < 1e-9, "a smooth arc"); + assert!(near(&at("spike", 5), &[0.0, 0.5, 1.0, 0.5, 0.0]), "straight up and down"); + assert!(near(&at("random", 3), &[0.5; 3])); + assert!(near(&at("straight", 1), &[0.0]), "one character takes the start value"); +} + +#[test] +fn a_straight_ramp_sizes_each_letter_and_skips_spaces() { + let mut s = session(); + let id = text(&mut s, "ab cd"); + select(&mut s, &[id]); + let n = steps(&s); + let out = s.execute(C, &json!({"attribute": "size", "from": 10, "to": 40})).unwrap(); + // `b` is 20 pt already: three characters change. + assert_eq!(out, json!({"count": 3, "ids": [id.0]})); + assert_eq!(steps(&s), n + 1, "one undo step"); + let sizes: Vec<(char, f64)> = styles(&s, id).into_iter().map(|(c, st)| (c, st.size)).collect(); + assert_eq!(sizes, [('a', 10.0), ('b', 20.0), (' ', 20.0), ('c', 30.0), ('d', 40.0)], "the space keeps its size"); + assert_eq!(obj(&s, id).plain_text(), "ab cd", "the text stays live"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(styles(&s, id).iter().all(|(_, st)| st.size == 20.0), "undone"); +} + +#[test] +fn spaces_can_count_too() { + let mut s = session(); + let id = text(&mut s, "a b"); + select(&mut s, &[id]); + s.execute(C, &json!({"attribute": "baselineShift", "from": 0, "to": 10, "skipSpaces": false})).unwrap(); + let shifts: Vec = styles(&s, id).iter().map(|(_, st)| st.baseline_shift).collect(); + assert_eq!(shifts, [0.0, 5.0, 10.0]); +} + +#[test] +fn every_attribute_is_set_as_the_character_panel_sets_it() { + let mut s = session(); + for (attribute, get) in [ + ("size", (|st: &CharStyle| st.size) as fn(&CharStyle) -> f64), + ("baselineShift", |st| st.baseline_shift), + ("rotation", |st| st.rotation), + ("hScale", |st| st.h_scale), + ("vScale", |st| st.v_scale), + ("tracking", |st| st.tracking), + ("leading", |st| st.leading.unwrap_or(-1.0)), + ("strokeWidth", |st| st.stroke_width), + ] { + let id = text(&mut s, "xyz"); + select(&mut s, &[id]); + s.execute(C, &json!({"attribute": attribute, "from": 2, "to": 6})).unwrap(); + let got: Vec = styles(&s, id).iter().map(|(_, st)| get(st)).collect(); + assert!(near(&got, &[2.0, 4.0, 6.0]), "{attribute}: {got:?}"); + } +} + +#[test] +fn each_object_gets_its_own_ramp() { + let mut s = session(); + let (a, b) = (text(&mut s, "abc"), text(&mut s, "de")); + select(&mut s, &[a, b]); + let out = s.execute(C, &json!({"attribute": "rotation", "from": 0, "to": 30})).unwrap(); + // Each first character is upright already. + assert_eq!(out["count"], json!(3)); + let turns = |id| styles(&s, id).iter().map(|(_, st)| st.rotation).collect::>(); + assert_eq!((turns(a), turns(b)), (vec![0.0, 15.0, 30.0], vec![0.0, 30.0])); +} + +#[test] +fn a_range_changes_only_its_characters() { + let mut s = session(); + let id = text(&mut s, "abcde"); + s.execute(C, &json!({"id": id.0, "start": 1, "end": 4, "attribute": "tracking", "from": 100, "to": 300})).unwrap(); + let tracking: Vec = styles(&s, id).iter().map(|(_, st)| st.tracking).collect(); + assert_eq!(tracking, [0.0, 100.0, 200.0, 300.0, 0.0]); +} + +#[test] +fn a_range_inside_a_multibyte_character_uses_character_boundaries() { + let mut s = session(); + let id = text(&mut s, "éx"); + let out = s.execute(C, &json!({"id": id.0, "start": 1, "end": 2, "attribute": "tracking", "from": 100, "to": 300})).unwrap(); + assert_eq!(out["count"], 1); + let tracking: Vec = styles(&s, id).iter().map(|(_, st)| st.tracking).collect(); + assert_eq!(tracking, [100.0, 0.0]); +} + +#[test] +fn random_values_stay_in_range_and_repeat_with_the_seed() { + let mut s = session(); + let id = text(&mut s, "randomness"); + select(&mut s, &[id]); + let run = |s: &mut Session, seed| { + s.execute(C, &json!({"attribute": "vScale", "from": 50, "to": 150, "shape": "random", "seed": seed})).unwrap(); + styles(s, id).iter().map(|(_, st)| st.v_scale).collect::>() + }; + let first = run(&mut s, 7); + assert!(first.iter().all(|v| (50.0..=150.0).contains(v)), "{first:?}"); + assert!(first.windows(2).any(|w| w[0] != w[1]), "values vary"); + assert_eq!(run(&mut s, 7), first, "same seed, same values"); + assert_ne!(run(&mut s, 8), first, "another seed, other values"); +} + +#[test] +fn values_follow_the_text_transform_as_the_character_panel_does() { + let mut s = session(); + let id = text(&mut s, "ab"); + select(&mut s, &[id]); + s.execute("object.scale", &json!({"sx": 200, "sy": 200})).unwrap(); + s.execute(C, &json!({"attribute": "size", "from": 20, "to": 40})).unwrap(); + let sizes: Vec = styles(&s, id).iter().map(|(_, st)| st.size).collect(); + // Shown sizes are the asked ones: stored sizes are half, the text being scaled twice. + let scale = obj(&s, id).style_scale().unwrap().points; + assert!(near(&sizes.iter().map(|v| v * scale).collect::>(), &[20.0, 40.0]), "{sizes:?} at {scale}"); +} + +#[test] +fn empty_text_and_only_spaces_change_nothing() { + let mut s = session(); + let id = text(&mut s, " "); + select(&mut s, &[id]); + let n = steps(&s); + assert_eq!(s.execute(C, &json!({})).unwrap(), json!({"count": 0, "ids": []})); + assert_eq!(steps(&s), n, "no step"); +} + +#[test] +fn characters_that_already_have_the_value_are_left_out() { + let mut s = session(); + let id = text(&mut s, "abc"); + select(&mut s, &[id]); + let p = json!({"attribute": "rotation", "from": 0, "to": 30}); + // The first character already has 0°. + assert_eq!(s.execute(C, &p).unwrap(), json!({"count": 2, "ids": [id.0]})); + let (before, n) = (s.doc().unwrap().doc.clone(), steps(&s)); + assert_eq!(s.execute(C, &p).unwrap(), json!({"count": 0, "ids": []}), "again: nothing to change"); + assert_eq!((s.doc().unwrap().doc.clone(), steps(&s)), (before, n), "no step"); +} + +#[test] +fn bad_params_are_refused_without_a_change() { + let mut s = session(); + let id = text(&mut s, "abc"); + select(&mut s, &[id]); + let (before, n) = (s.doc().unwrap().doc.clone(), steps(&s)); + for p in [ + json!({"attribute": "colour"}), + json!({"attribute": 3}), + json!({"shape": "zigzag"}), + json!({"attribute": "size", "from": 0}), + json!({"attribute": "size", "to": 5000}), + json!({"attribute": "hScale", "from": -1}), + json!({"from": "big"}), + json!({"to": f64::MAX}), + json!({"seed": -1}), + json!({"skipSpaces": "yes"}), + json!({"ids": [999_999]}), + json!({"id": id.0, "start": -1}), + json!({"id": id.0, "start": 1, "end": 1.5}), + json!({"id": id.0, "end": "2"}), + ] { + assert!(s.execute(C, &p).is_err(), "{p}"); + assert_eq!(s.doc().unwrap().doc, before, "{p}"); + assert_eq!(steps(&s), n, "{p}"); + } +} + +#[test] +fn it_needs_a_selection() { + let mut s = session(); + text(&mut s, "abc"); + select(&mut s, &[]); + assert!(s.execute(C, &json!({})).is_err()); +} diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index dc83d24bd..6507001c7 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Add New Fill", ["Window", "Appearance"], Some("Cmd+/"), - "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row (a copy of its top fill; type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids` and no `target: \"contents\"`, the new fill is a copy of that row, right above it, and becomes the active row", + "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row", has_doc, |s, p| add_item(s, p, true) ), @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Add New Stroke", ["Window", "Appearance"], Some("Cmd+Alt+/"), - "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row (a copy of its top stroke, else the default stroke)", + "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt", has_doc, |s, p| add_item(s, p, false) ), @@ -178,6 +178,23 @@ pub(crate) struct ActiveItem { } impl Session { + /// The method returns explicit target parameters and the selected appearance effect list. + pub fn appearance_effect_target(&self, params: &Value) -> Option<(Value, &[vectorcraft_doc::Effect])> { + let mut ids = appearance_targets(self, params).ok()?; + let item = item_target(self, params, "effect.apply").ok()?; + let anchor = item.anchor(self, &ids); + let node = self.active()?.doc.node(*ids.get(anchor)?)?; + let at = item.effects_item(&node.appearance, "effect.apply").ok()?; + let effects = node.appearance.effects_at(at)?; + ids.swap(0, anchor); + let ids: Vec = ids.into_iter().map(|id| id.0).collect(); + let aligned = match item { + ItemTarget::Item { aligned, .. } => aligned, + ItemTarget::Top => aligned(params), + }; + Some((json!({"ids": ids, "item": at, "aligned": aligned}), effects.as_slice())) + } + /// The Appearance panel's active fill/stroke: a paint-order index into the first selected /// object's stack, while the selection it was chosen for is unchanged. pub fn appearance_item(&self) -> Option { @@ -259,22 +276,24 @@ impl ItemTarget { /// This target for each of `ids` (in their order): an aligned item (a row of the first /// target's stack, or for the active item of the first selected object's, see /// [`ItemTarget::anchor`]) is, in each other target, the item equal to it there (see - /// [`SharedStack`]); `None` for a target that has none, which the edit leaves alone. A row the - /// targets don't share, and any other target, stands for itself in every target. + /// [`SharedStack`]), each target matched against that stack on its own, whatever the other + /// targets have. In a target without an equal item, and for any other target, the row stands + /// for itself. Every entry is `Some`. pub(crate) fn per_target(self, s: &Session, ids: &[NodeId]) -> Vec> { let ItemTarget::Item { index, explicit, aligned: true } = self else { return vec![Some(self); ids.len()] }; let Some(st) = s.active().filter(|_| ids.len() >= 2) else { return vec![Some(self); ids.len()] }; let k = self.anchor(s, ids); - let mut order = ids.to_vec(); - order.swap(0, k); - let Some(shared) = shared_stack(&st.doc, &order) else { return vec![Some(self); ids.len()] }; - if !shared.has_item(index) { - return vec![Some(self); ids.len()]; - } - let mut per: Vec> = - (0..order.len()).map(|j| shared.item_in(index, j).map(|index| ItemTarget::Item { index, explicit, aligned: true })).collect(); - per.swap(0, k); - per + let Some(first) = ids.get(k).and_then(|id| st.doc.node(*id)) else { return vec![Some(self); ids.len()] }; + ids.iter() + .enumerate() + .map(|(j, id)| match st.doc.node(*id) { + Some(n) if j != k => { + let at = SharedStack::of(&[&*first.appearance, &*n.appearance]).item_in(index, 1); + Some(at.map_or(self, |index| ItemTarget::Item { index, explicit, aligned: true })) + } + _ => Some(self), + }) + .collect() } /// Where among `ids` (the targets, in paint order) the object whose stack this item is a row of @@ -340,11 +359,23 @@ pub(crate) fn appearances_differ(d: &vectorcraft_doc::Document, ids: &[NodeId]) /// Indices `idx` of the first target's items (aligned with `aligned`) in each of `ids`: the first /// target's own, and in each other one the items equal to them there (see [`SharedStack`]), each -/// index on its own: one the targets don't share is left out of the others (never taken as the -/// same index there). Not aligned, or one target: the same indices in each. +/// index and each target on its own: an index a target doesn't share with the first is left out +/// of that target (never taken as the same index there), whatever the other targets have. Not +/// aligned, or one target: the same indices in each. pub(crate) fn items_per_target(d: &vectorcraft_doc::Document, ids: &[NodeId], idx: &[usize], aligned: bool) -> Vec> { - match shared_stack(d, ids).filter(|_| aligned) { - Some(sh) => (0..ids.len()).map(|k| if k == 0 { idx.to_vec() } else { idx.iter().filter_map(|i| sh.item_in(*i, k)).collect() }).collect(), + let aps: Option> = ids.iter().map(|id| d.node(*id).map(|n| &*n.appearance)).collect(); + match aps.filter(|aps| aligned && aps.len() >= 2) { + Some(aps) => aps + .iter() + .enumerate() + .map(|(k, ap)| match aps.first() { + Some(first) if k > 0 => { + let sh = SharedStack::of(&[*first, *ap]); + idx.iter().filter_map(|i| sh.item_in(*i, 1)).collect() + } + _ => idx.to_vec(), + }) + .collect(), None => vec![idx.to_vec(); ids.len()], } } @@ -490,8 +521,8 @@ fn set_active_item(s: &mut Session, p: &Value) -> Result { fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { let ids = require_targets(s, p, if fill { "appearance.addFill" } else { "appearance.addStroke" })?; let defaults = s.paint.clone(); - // The panel's active Fill row, when the new fill goes on the selection: the new fill is a copy - // of that row, right above it in each target that has it (others get a copy of their top fill). + // The panel's active Fill row, when the new fill goes on the selection: the new fill takes that + // row's paint, right above it in each target that has it (others take their top fill's paint). // Where the new item went in on the active item's object, when it is an image. let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied()); // The active row is a row of the active object's stack, which need not come first in `ids` @@ -514,6 +545,7 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { } _ => vec![None; ids.len()], }; + let before = s.doc()?.doc.clone(); let mut inserted = None; // Where the new fill went in on the active item's object, when it went above the active row. let mut above = None; @@ -529,8 +561,9 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { } continue; } - // A copy of the top fill (stroke); without one, type's characters' and otherwise the - // default paint, so a new fill on a group or type shows. + // The top fill's (stroke's) paint; without one, type's characters' and otherwise the + // default paint, so a new fill on a group or type shows. A new stroke takes the top + // stroke's weight when that stroke has paint, at least 1 pt. let paint = match (n.appearance.paint_at(None, fill), &n.kind) { (Some(p), _) => p.clone(), (None, NodeKind::Text(_)) => super::paint::proxy_paint(n, !fill, None), @@ -560,6 +593,7 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { } } } + super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); Ok(()) })?; // A fill added above the active row becomes the active row; otherwise the rows above the new @@ -578,12 +612,28 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result { clear(n); } } - Ok(count_changed(d, &before, &ids)) + let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); + Ok(count_changed(d, &before, &ids) + usize::from(kept)) })?; s.active_appearance_item = None; ok() } +/// Each of `ids` with both kinds of row (fill, then stroke), for [`super::graph::capture_series_opacity`] after an +/// edit that replaces whole stacks. +fn both_kinds(ids: &[NodeId]) -> Vec<(NodeId, bool)> { + ids.iter().flat_map(|id| [(*id, true), (*id, false)]).collect() +} + +/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost row has another opacity in `d` than +/// in `before` (no row counts as opaque), for [`super::graph::capture_series_opacity`]. +fn top_opacity_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<(NodeId, bool)> { + let top = |d: &vectorcraft_doc::Document, id: NodeId, fill: bool| { + d.node(id).and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill).map(AppearanceItem::opacity)).unwrap_or(1.0) + }; + ids.iter().flat_map(|id| [(*id, true), (*id, false)]).filter(|(id, fill)| top(before, *id, *fill) != top(d, *id, *fill)).collect() +} + /// Clear Appearance on `n`: no fill, no stroke, and the object's Opacity row back to Default. A /// group keeps no fill or stroke rows of its own. fn clear(n: &mut Node) { @@ -619,7 +669,9 @@ fn transfer(s: &mut Session, p: &Value) -> Result { clear(n); (n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false); } - Ok(count_changed(d, &before, &[source, target])) + let changed = if copy { vec![target] } else { vec![source, target] }; + let kept = super::graph::capture_series_opacity(d, &both_kinds(&changed)); + Ok(count_changed(d, &before, &[source, target]) + usize::from(kept)) })?; s.active_appearance_item = None; Ok(json!({ "source": source.0, "target": target.0 })) @@ -672,7 +724,8 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result { } } } - Ok(count_changed(d, &before, &ids)) + let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); + Ok(count_changed(d, &before, &ids) + usize::from(kept)) })?; s.active_appearance_item = None; ok() @@ -694,9 +747,14 @@ fn set_item(s: &mut Session, p: &Value) -> Result { let before = s.doc()?.doc.clone(); let per = items_per_target(&before, &ids, &[idx], aligned(p)); super::overprint::edit_counted(s, "Appearance", |d| { + let mut edited = Vec::new(); for (id, at) in ids.iter().zip(&per) { let Some(&idx) = at.first() else { continue }; let Some(n) = d.node_mut(*id) else { continue }; + let items = &n.appearance.items; + if let Some(fill) = items.get(idx).map(AppearanceItem::is_fill).filter(|f| items.iter().rposition(|it| it.is_fill() == *f) == Some(idx)) { + edited.push((*id, fill)); + } if p.get("weight").is_none() && let Some(pa) = &paint { @@ -727,7 +785,9 @@ fn set_item(s: &mut Session, p: &Value) -> Result { *vis = v; } } - Ok(count_changed(d, &before, &ids)) + // The graph keeps an opacity set on the topmost fill or stroke of its series art for when it is drawn again. + let kept = p.get("opacity").and_then(Value::as_f64).is_some() && super::graph::capture_series_opacity(d, &edited); + Ok(count_changed(d, &before, &ids) + usize::from(kept)) })?; if let Some(pa) = &paint { s.remember_paint(pa); @@ -770,7 +830,8 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { } } } - Ok(count_changed(d, &before, &ids)) + let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); + Ok(count_changed(d, &before, &ids) + usize::from(kept)) })?; s.remap_appearance_item(&ids, |a| (!idx.contains(&a)).then(|| a - idx.iter().filter(|i| **i < a).count())); ok() @@ -796,7 +857,8 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { if aligned(p) && to.is_some() && shared_stack(&s.doc()?.doc, &ids).is_some() { return Err(bad(C, "`to` takes the rows of one object's stack, not aligned rows")); } - let per = items_per_target(&s.doc()?.doc, &ids, &idx, aligned(p)); + let before = s.doc()?.doc.clone(); + let per = items_per_target(&before, &ids, &idx, aligned(p)); s.edit("Duplicate Item", |d, _| { let mut any = false; for (id, idx) in ids.iter().zip(&per) { @@ -834,7 +896,11 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { } } } - if any { Ok(()) } else { Err(bad(C, format!("no item at index {}", idx[0]))) } + if !any { + return Err(bad(C, format!("no item at index {}", idx[0]))); + } + super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); + Ok(()) })?; // Rows at or above each copy move up by one (by the block, for copies placed at `to`). let m = idx.len(); @@ -893,7 +959,8 @@ fn move_item(s: &mut Session, p: &Value) -> Result { n.appearance.set_contents_at(k); } } - Ok(count_changed(d, &before, &ids)) + let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); + Ok(count_changed(d, &before, &ids) + usize::from(kept)) })?; // The moved row stays the active one; the rows it passed shift by one. s.remap_appearance_item(&ids, |a| { @@ -936,7 +1003,8 @@ fn move_items(s: &mut Session, p: &Value, ids: &[NodeId], to: usize) -> Result Result { s.eyedropper_sample = Some(node); } let range = range.filter(|(id, _, _)| targets.contains(id)); + // The highlighted characters' type objects and ranges: after Select All in threaded text, all + // the text of every frame of the story. + let story = super::textedit::editing_story(s); + let ranges: Vec<(NodeId, Option<(usize, usize)>)> = match range { + Some((id, _, _)) if story.contains(&id) => story.iter().map(|f| (*f, None)).collect(), + Some((id, start, end)) => vec![(id, Some((start, end)))], + None => vec![], + }; // Character styles cannot represent object transparency. Refuse before even changing // paint defaults; applying it to the object would also alter the unselected characters. - if let Some((id, _, _)) = range.filter(|_| attrs.appearance.transparency) { - let target = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?; - if picked.opacity != 1.0 || picked.blend != BlendMode::Normal || target.opacity != 1.0 || target.blend != BlendMode::Normal { - return Err(bad( - C, - "object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object", - )); + if attrs.appearance.transparency { + for (id, _) in &ranges { + let target = s.doc()?.doc.node(*id).ok_or(EngineError::NoNode(*id))?; + if picked.opacity != 1.0 || picked.blend != BlendMode::Normal || target.opacity != 1.0 || target.blend != BlendMode::Normal { + return Err(bad( + C, + "object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object", + )); + } } } if !reverse { @@ -1051,19 +1129,23 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { if targets.is_empty() || attrs.is_empty() { return Ok(json!({ "ids": [] })); } - if let Some((id, start, end)) = range { + if !ranges.is_empty() { + let ids: Vec = ranges.iter().map(|(f, _)| *f).collect(); let before = s.doc()?.doc.clone(); super::overprint::edit_counted(s, "Eyedropper", |d| { - let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; - let placed = picked.placed(Some(t.local_bounds())); - vectorcraft_text::edit::style_range(&mut t.runs, start, end, |st| picked.run(st, &placed, &attrs)); - if let Some((_, para)) = picked.text.as_ref().filter(|_| attrs.paragraph) { - t.edit_paras(Some(t.paragraphs_in(start, end)), |p| *p = para.clone()); + for &(id, range) in &ranges { + let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; + let (start, end) = range.unwrap_or((0, vectorcraft_text::edit::runs_len(&t.runs))); + let placed = picked.placed(Some(t.local_bounds())); + vectorcraft_text::edit::style_range(&mut t.runs, start, end, |st| picked.run(st, &placed, &attrs)); + if let Some((_, para)) = picked.text.as_ref().filter(|_| attrs.paragraph) { + t.edit_paras(Some(t.paragraphs_in(start, end)), |p| *p = para.clone()); + } + super::typecmd::refresh_bounds(t); } - super::typecmd::refresh_bounds(t); - Ok(count_changed(d, &before, &[id])) + Ok(count_changed(d, &before, &ids)) })?; - return Ok(json!({ "ids": [id.0] })); + return Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })); } // When every target comes out as it was, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); @@ -1073,7 +1155,8 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { picked.apply(n, &attrs, append); } } - Ok(count_changed(d, &before, &targets)) + let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &targets)); + Ok(count_changed(d, &before, &targets) + usize::from(kept)) })?; Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::>() })) } diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 04498111c..8f36e16f6 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -107,10 +107,14 @@ impl BrushLibrary { /// `name`: the brushes and every resource their art uses. pub fn from_document(src: &Document, names: &[String], name: String) -> std::result::Result { let all = brush::library(src); - if let Some(n) = names.iter().find(|n| !all.iter().any(|b| b.name == **n)) { + // Names looked up in sets: scanning the lists for each brush made a library of N brushes + // cost N² comparisons. + let have: HashSet<&str> = all.iter().map(|b| b.name.as_str()).collect(); + if let Some(n) = names.iter().find(|n| !have.contains(n.as_str())) { return Err(format!("no brush `{n}`")); } - let picked = all.iter().filter(|b| names.is_empty() || names.contains(&b.name)); + let wanted: HashSet<&str> = names.iter().map(String::as_str).collect(); + let picked = all.iter().filter(|b| names.is_empty() || wanted.contains(b.name.as_str())); let clip = Clipboard::gather(src, picked.map(|b| stroke(&b.name)).collect()); let mut doc = clip.to_document(); for l in &mut doc.layers { @@ -120,7 +124,8 @@ impl BrushLibrary { } // The brushes picked and those their art paints with, in the panel order of the document // they came from. - let brushes: Vec = all.into_iter().filter(|b| clip.brushes.iter().any(|c| c.name == b.name)).collect(); + let kept: HashSet<&str> = clip.brushes.iter().map(|c| c.name.as_str()).collect(); + let brushes: Vec = all.iter().filter(|b| kept.contains(b.name.as_str())).cloned().collect(); brush::store(&mut doc, &brushes); doc.title = name.clone(); Ok(Self { name, brushes, doc }) diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index adfb221ef..a6505a057 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -19,7 +19,7 @@ use super::edit::{outermost, selected_roots}; use super::pathops::{remove_each, separate}; use super::swatch::{anchor, names_param}; use super::{ - CommandSpec, Result, Session, bad, check_numbers, cmd, color_value, find_command, has_doc, has_selection, id_param, named_ids, number_or, ok, + CommandSpec, Result, Session, bad, check_numbers, cmd, color_param, find_command, has_doc, has_selection, id_param, named_ids, number_or, ok, str_param, switch_or, }; use crate::EngineError; @@ -35,7 +35,9 @@ use brushes::{ }; use dynamic::{symbol_instance_reset, symbol_instance_set_appearance}; use symbols::selected_instances; -pub(crate) use symbols::{instance_in_place, natural_size, normalize, place_xf, set_natural_size, store_symbol}; +pub(crate) use symbols::{ + expand_instances_under, expandable_instances, instance_in_place, natural_size, normalize, place_xf, set_natural_size, store_symbol, +}; use symbols::{ symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_list, symbol_new, symbol_options, symbol_place, symbol_replace, symbol_select_instances, symbol_set_current, symbol_spray, symbol_unused, symbol_update, @@ -201,7 +203,7 @@ pub fn specs() -> Vec { "Break Link to Symbol", [], None, - "{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them → {ids}", + "{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask → {ids}", has_selection, symbol_break_link ), @@ -302,7 +304,7 @@ pub fn specs() -> Vec { "Symbolism Tool", [], None, - "{tool: shift|scrunch|size|spin|stain|screen|style, points: [[x,y]…], radius?: 40, intensity?: 1..10 (5), alt?: bool, color?, keepModel?: bool (as paint.setFill), style?: name} adjust instances near the points (selection, else all) → {count}", + "{tool: shift|scrunch|size|spin|stain|screen|style, points: [[x,y]…], radius?: 40, intensity?: 1..10 (5), alt?: bool, color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), style?: name} adjust instances near the points (selection, else all) → {count}", has_doc, symbol_adjust ), diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 9a3606311..6ab7acad0 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -1,7 +1,7 @@ use super::dynamic::{fit_overrides, type_id, type_param}; use super::{ Affine, AppearanceItem, Arc, CURRENT_SYMBOL, Color, Document, EngineError, FillLayer, HALF, Node, NodeId, NodeKind, Paint, Point, Result, - SIZES_KEY, Session, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration, Value, Vec2, bad, brush, color_value, ids_json, json, + SIZES_KEY, Session, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration, Value, Vec2, bad, brush, color_param, ids_json, json, name_param, number_or, ok, remove_each, roots, selection_art, separate, set_unknown, str_param, switch_or, }; @@ -341,15 +341,14 @@ pub(super) fn symbol_place(s: &mut Session, p: &Value) -> Result { /// The plain art of instance `inst` in document space (fresh ids), with the instance's own /// appearance changes to a dynamic symbol's art when `own`, placed through its symbol's 9-slice -/// guides when it has them. +/// guides when it has them. The instance's opacity mask goes on the art, or on a group around it +/// when the art has a mask of its own. fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option { let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None }; let sym = d.symbols.iter().find(|s| &s.name == symbol)?; let mut art = if own { vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, inst) } else { (*sym.art).clone() }; - if let Some(f) = inst.appearance.fill() - && let Some(c) = f.paint.color() - { - brush::tint_node(&mut art, &c, f.opacity); + if let Some((color, amount)) = brush::stain(inst) { + brush::tint_node(&mut art, &color, amount); } let art = d.place_symbol_art(symbol, &art, *xf)?; let mut art = d.reid(&art); @@ -361,13 +360,21 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option { art.opacity *= inst.opacity; art.blend = inst.blend; } + if let Some(mask) = &inst.extras().mask { + if art.extras().mask.is_some() { + let id = d.alloc_id(); + art = Node::group(id, vec![Arc::new(art)]); + } + art.extras_mut().mask = Some(mask.clone()); + } Some(art) } /// The most objects [`expanded_instance`] makes of instance `inst` in `d` (`own` as there): its /// symbol's object count with the art of its opacity masks, or /// [`vectorcraft_doc::NineSliceMap::most_placed`] when it is placed through 9-slice guides, plus -/// the group that opacity or a blending mode adds. 0 for any other object. +/// the group that opacity, a blending mode or the instance's opacity mask adds, plus the art of +/// the instance's opacity mask. 0 for any other object. fn expanded_bound(d: &Document, inst: &Node, own: bool) -> u64 { let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return 0 }; let Some(sym) = d.symbols.iter().find(|s| &s.name == symbol) else { return 0 }; @@ -378,7 +385,8 @@ fn expanded_bound(d: &Document, inst: &Node, own: bool) -> u64 { None if dynamic => vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, inst).count_with_masks() as u64, None => sym.art.count_with_masks() as u64, }; - most.saturating_add(1) + let mask = inst.extras().mask.as_ref().map_or(0, |m| m.art.count_with_masks() as u64); + most.saturating_add(1).saturating_add(mask) } /// Replace instances `ids` with their art ([`expanded_instance`], `own` as there). Returns the @@ -402,7 +410,64 @@ fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result