mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 05:36:46 -06:00
Conflicts resolved per file (both sides kept unless noted):
crates/engine/src/atomic.rs | 1 | main's: `write_file` removed (main routes command writes through `crate::io::write_file`); its one caller in file.rs now uses `crate::io::write_file`
crates/engine/src/cmd/datamerge/fill.rs | 1 | next's `Graphic` literal (has `clip` and `wrap`, which next's `Graphic` carries)
crates/engine/src/cmd/datamerge/mod.rs | 1 | main's `read_path_capped` (crate::io metadata/read_capped, roots checked); next's capped read is the same behaviour
crates/engine/src/cmd/datamerge/parse.rs | 1 | main's (`crate::io::metadata`, `crate::io::Metadata`)
crates/engine/src/cmd/edit.rs | 1 | next's `spread_param(p, "edit.paste")` (the second argument is the command id on both sides)
crates/engine/src/cmd/export.rs | 3 | main's: EPUB params doc, `toc_title` once (main's position; next's duplicate dropped)
crates/engine/src/cmd/file.rs | 7 | place doc: next's (lists page/rect/autoflow params #803 checks); next's recent-file functions + main's `foreign_document`; next's open (load_bytes, warnings, non-DesignCraft refusal) with main's checked `crate::io::read_file` for the bytes; main's `check_write` be
crates/engine/src/cmd/find.rs | 7 | main's Find scope (FindContext, scope/selection/story, continuation, UTF-8 repair) on next's `designcraft_compose::grep::Grep` (dialect GREP): find uses `find_all` capped at MAX_MATCHES, change uses `replacements`/`find_all` (literal change text not expanded), fin
crates/engine/src/cmd/fonts.rs | 1 | next's (`has_style`, resolved/replacement face); `has_style` already compares styles ignoring spacing and case, which is main's `same_style` fix; main's test kept
crates/engine/src/cmd/interchange.rs | 1 | next's `import_idml_report_with_previews` (previews for unreadable links)
crates/engine/src/cmd/layout.rs | 8 | pages.insert: main's overflow-safe `after` + next's checked `parent_ref`; main's duplicateSpread doc; layout.section: next's `SectionEdit` (already keeps unnamed settings, #717) with main's `d.repaginate()` moved into `SectionEdit::apply` (remove and set paths),
crates/engine/src/cmd/links.rs | 5 | next's links.list (no doc clone); main's `text_status` via crate::io; `use std::io::Read` dropped (unused after main's read_capped); test literals main's. `link_missing` (next) uses `crate::io::exists` for the file-guard scan
crates/engine/src/cmd/object.rs | 4 | object.fill/stroke tint-alone: main's (23e1680e7; out-of-range tint is an error, which main's test asserts); object.fit doc: next's (lists `axis`)
crates/engine/src/cmd/paths.rs | 1 | next's (shatai transform on outlines)
crates/engine/src/cmd/place_text.rs | 1 | next's `add_flow_frame` (used by autoflow)
crates/engine/src/cmd/preflight.rs | 2 | next's overset/unplacedStory issue (its `overset_location` already applies the parent transform main's #738 fix adds); next's two test modules kept with main's
crates/engine/src/cmd/scripts.rs | 1 | combined: next's `in_undo_step = false` (#406) + main's full rollback of a failed script (#709)
crates/engine/src/cmd/style.rs | 14 | param docs next's (gradient swatch.create, swatch stops, model colours); create_para next's; rename_style main's structure (rename entry, then `redirect_style_refs`), redirect has next's full reference set (cells, defaults, notes, TOC) + main's never-based-on-it
crates/engine/src/cmd/text.rs | 10 | main's selectSentence/Paragraph/Story + select_around; next's selectionAttrs (mixed) + main's openTypeFeatures/type.feature; main's comments; tracked typing: next's `reserve_command_text` + main's `insert_format_at`; empty-paragraph mark formatting main's (with `
crates/engine/src/cmd/thread.rs | 1 | next's `spread_param(p, "text.thread")`
crates/engine/src/dtd.rs | 1 | next's linear, depth-bounded `expand_parameter_entities` with main's budget (input + 1 MiB, `MAX_EXPANDED_BYTES`) and message ("expand the DTD beyond"); 16 MiB constant removed; both sides' tests pass on message
crates/engine/src/guard.rs | 2 | main's whole-transaction snapshot (docs, active, journal; #742) + next's `in_undo_step` restore; tests both
crates/engine/src/lib.rs | 5 | LoadedGraphic doc next's; next's `new_document_preview`; execute: main's `discard_stale_find_scope()` before next's nested-undo run; modules both (tests_autosize, tests_file_guard, tests_params, tests_roots)
crates/engine/src/recovery.rs | 1 | next's test (superset of main's assertion)
crates/engine/src/tests.rs | 4 | moved tests deduplicated (identical on both sides); next's preflight and direct-selection/kerning tests + main's style-delete, locked-frame and baseline-fit tests
crates/engine/src/tests_idml.rs | 3 | main's order (drop cap and margins tests identical on both sides, once each)
crates/ui-egui/src/canvas.rs | 3 | next's screen_options/DisplayKey block and tests kept; two tests that auto-merge had added twice (parent_guides_on_a_page_are_not_picked_there, baseline_grid_lines_are_the_ones_on_the_page) kept once; main's `typing`/`screen_at`/`click` helpers and its tests clicks
crates/ui-egui/src/control.rs | 3 | main's `count` check and pending budget (control queue + synthetic queue); exact-budget test runs both sides' cases (click 255 on 9,492 pending; drag 9492×255)
crates/ui-egui/src/cursors.rs | 1 | main's (same Pen cursor set)
crates/ui-egui/src/dialogs.rs | 14 | dialog body (newDocument … alert match) is next's text, with main's Find/Change scope reset (`set_field`, `reset_find_scope`, `find_change_params`) and main's Create Guides / Margins and Columns arms ported in; `Dialog::new` ruby section (next) + main's `set_fi
crates/ui-egui/src/i18n.rs | 7 | ka and pl modules, languages, statics and lookups both; `category_first` covers uk, ru, pl, ka and `count_label` keeps next's Czech plurals; shared TABLE union; next's duplicate rows for Auto, Number:, Gutter:, Top:, Bottom:, Inside:, Outside:, Left:, Right: dropped
crates/ui-egui/src/i18n/ar.rs | 2 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/it.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/ja.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/pt_br.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/uk.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/icons.rs | 2 | both tests (main's typography icon, next's distribute icons)
crates/ui-egui/src/lib.rs | 11 | UiState: next's tabs_panel + main's last_color/last_gradient; `cursors` field once; next's SVG paste and paste-on-spread-in-view; next's extensionless document open; opens_as_document next's comment; tests both (next's control bar / key-name tests, main's rich black
crates/ui-egui/src/menus.rs | 15 | app.language doc next's text with pl and ka; UI_COMMANDS both (next's Tabs, Toggle Presentation, Apply None text; main's panel commands, Apply Color, Apply Gradient); Edit › Interface Language stays out (next moved it to Preferences); Type menu main's order with
crates/ui-egui/src/panels/glyphs.rs | 1 | next's (already has main's scroll-bar fix); the scroll-bar test module auto-merge had added twice kept once
crates/ui-egui/src/panels/layers.rs | 3 | next's drag-and-drop `object_rows`, with main's features ported into it: selection reveal (layer opens, row scrolls into view once), selected rows highlighted (also a group whose member is selected), double-click rename in place (`object.rename` queued like
crates/ui-egui/src/panels/mod.rs | 6 | tabs + typography modules and color_panel_tests; SelInfo has wrap_side/wrap_contour and wrap_offsets (offsets from the graphic's own wrap when it is selected); next's swatch_picker returning the chip response; next's content_selection_tests
crates/ui-egui/src/panels/pages.rs | 1 | next's (parents dragged by id)
crates/ui-egui/src/panels/properties.rs | 4 | next's swatch popup (Apply To, Tint); main's four wrap offsets then next's Wrap To / Contour Type; test fixtures main's (`..Default::default()`, no unused builder)
crates/ui-egui/src/panels/swatches.rs | 3 | main's Delete Swatch button + next's New Swatch comment; next's draggable rows; click: main's PICKED + fill/stroke in front, with next's no-apply on double-click
crates/ui-egui/src/toolbar.rs | 2 | main's Apply Color / Gradient / None wells and functions; next's apply_none doc line
crates/compose/src/lib.rs | 5 | next's line loop kept (mark_leading + last_line_mark, wrap segments/carry, grid gyoudori); main's mark-chars base_leading block (auto-merged) stays beside it. `fits`: next's #106 rule (baseline fit for horizontal frames, full descent for paths, vertical text, footnote
crates/compose/src/tests.rs | 5 | next's tests (tombstone set, sliver/one-sided/contour/clipping wrap tests, borders, lists, kenten, grid, anchored wrap, balance, shaped frames) plus main's an_empty_last_paragraph_isnt_overset; main's wrap_object_beside_one_narrow_column_leaves_the_other_alone re-ad
crates/doc/src/assets.rs | 1 | main's doc comment (same meaning)
crates/doc/src/build.rs | 3 | parent_page_for: next's parent_page_for_side (RTL-aware); single_sided_page_shows_first_parent_page test kept once, at main's position
crates/doc/src/item.rs | 1 | next's rotated_group_bounds_follow_the_children_outlines test kept
crates/doc/src/lib.rs | 1 | next's kerning_increment field kept; main's "Bulleted & Numbered" wording
crates/doc/src/page.rs | 1 | next's outer_page_indices + parent_page_for_side (integration of parent-margins with rtl-master-sides); main's parent_page_index dropped, its only caller (build.rs) uses parent_page_for_side
crates/doc/src/slice.rs | 5 | extract: next's `marked()` (get-based, fix_notes/fix_endnotes/fix_marks; main's version indexed slices and could panic); insert_story: main's take() limit on endnote/editorial payload copies with next's id comment, redundant `let ne`/`let nn` dropped; main's excess_note
crates/geom/src/corners.rs | 1 | next's box_outline_rounds_only_closed_corners test kept
crates/render/src/damage.rs | 1 | next's wrapping-anchored-object damage test kept
crates/render/src/fx.rs | 2 | feather: next's choke/corners sigma+join inside main's structure (body and clip at device resolution, with_filters(.., None)); inner glow centre: next's blend `mix` with main's clip argument (bp, xf)
crates/render/src/lib.rs | 3 | next's layer comment; next's front baseline grid draw after items; gradient match on g.kind (main's tuple scrutinee did not match the single-pattern arms)
crates/render/src/text.rs | 5 | paint_glyphs: main's paint_stroke closure structure with next's stroke overprint (overprinting helper, stroke `(TextPaint, bool)`); callers pass next's overprint_fill/overprint_stroke
crates/render/tests/placed_pdf.rs | 1 | main's `near` helper version
crates/tools/src/lib.rs | 1 | main's (PenConvert already declared below, avoids a duplicate variant)
crates/tools/src/pen.rs | 1 | main's module doc
crates/tools/src/select.rs | 1 | next's Drag::Corner variant (live corners) kept
crates/tools/src/tests.rs | 4 | next's tests (content drag, live corners, table border drag, eyedropper matchFrom, fitting handles, pen cursors once); eyedropper_ignores_inactive keeps next's samples_image check (main's exact-JSON compare lacks matchFrom) with main's `..Default::default()` Asset lit
crates/tools/src/text.rs | 2 | next's table border drag (BorderDrag, border_at) kept, then main's comment on locked text frames
crates/tools/src/xform.rs | 2 | main's explicit `Vec<&Item>` for shown children; next's eyedropper matchFrom (integration 796e9d713)
Cargo.toml | 2 | main's craft-pdf workspace dep added (craft-print stays once, in next's craft-libs block); [patch.crates-io] keeps next's winit (rust-windowing #4694 backport) and adds main's krilla patch; main's skylartaylor winit left out (see UNSURE 1)
Cargo.lock | 1 | union: next's craft-atomic and craft-cms entries plus main's craft-pdf entry; needs `cargo` normalisation
README.md | 1 | Open bullet combines INDD import via indd-utils (next) and editable PowerPoint export (main); main's paragraph break kept
contributors/people.toml | 1 | both people kept (switch87 from next, Nmarello from main)
apps/designcraft-cli/src/main.rs | 4 | next's `Export` enum dispatch kept; main's .pptx export added as `Export::Pptx` (run prints slides and warnings, script runs file.exportPptx); main's `--pdf-options` null-as-{} parse; usage lines list .pptx
apps/designcraft/src/main.rs | 3 | `startup.leave()` right after `app.ui` (main #380) and next's macOS `sync_movable` kept after the IME discard; next's Apple Events install kept; main's macOS Dock Quit `with_close_requested_on_terminate` block left out (needs the skylartaylor winit; UNSURE 1)
crates/fonts/src/fontdb.rs | 9 | FONT6 aliases (next) combined with #911 WWS names (main): ScannedFace and both CatalogEntry constructors carry `wws` and `aliases`; `canonical_family` falls back to WWS family names; `exact_style_traits` canonicalises then tries typographic, then WWS names (`named_st
crates/format/src/lib.rs | 1 | tests: both sides kept (next's r7a nullable-default tests; main's oversized ZIP entry/asset/entry-count tests)
crates/idml/src/import.rs | 11 | main's bounded `Package` reader under next's `import_idml_report_with_previews` (metadata read through `pkg.part`); next's `tab_list_repaired` kept; ruler origin: next's `settings.ruler_origin`, and main's guide import reads it (main's private `RulerOrigin` enum and
crates/idml/src/tests.rs | 6 | item-level union: all next tests plus main's new ones (ruler guides, package limits, CJK leading model, liquid page rules module, mojikumi); no test dropped
crates/mcp/src/backend.rs | 1 | test comment merged from both
crates/pdf/src/tests.rs | 1 | tests: both sides kept
crates/pdf/src/text.rs | 4 | next's (skew `tan`, cluster `start`, ruby overprint warning); main had no change here beyond base
crates/textimport/src/docx.rs | 5 | next's (ListCounter integration for list levels, page-break test position); main's side was the older form of the same code
crates/epub/src/lib.rs | 30 | non-test code is next's (StoryWriter holds img_src/images/no_alt/heading_ids; covers main's #787/#791/#782 fixes, plus #314 group transforms, anchored images in images_without_alt, endnote story skipped in the TOC); tests: next's set plus main's extra css_weight_style a
docs/control-protocol.md | 2 | next's rows (ui.paste, count 1–255, documentGrid) and wording
docs/file-format-parity.md | 1 | rows combined (next's ICC notes, main's TIFF indexed colour)
docs/gaps.md | 2 | status change notes unioned; Flex Layout removed from missing (main implemented it), next's Parent Options sentence kept
docs/localization-parity.md | 4 | Polish and Georgian rows; 5 languages beyond the twelve; file list includes cs and ka; both history rows
docs/roadmap.md | 1 | M1 next's (#186 fixed on next), M2 main's IME wording
docs/target-app-parity.md | 6 | next's status line; File formats row next's, UI/UX row main's (IME no longer failing); UI line next's plus main's Swatches detail; one Page tool line (main's fuller one); Load styles and main's fuller Pathfinder line both kept, main's duplicate short Pathfinder line r
docs/ui-parity.md | 1 | IME row main's, Font menu row next's
i18n/fr.rs: five rows dropped that main's shared table now has (Row, Open Path, Flex Layout, Current Page Number, Rulers).
642 lines
32 KiB
Rust
642 lines
32 KiB
Rust
//! Path operations from the Object and Type menus: Make / Release Compound Path, Create Outlines,
|
||
//! and the Scissors tool's split.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use designcraft_doc::{Content, Document, Fill, Item, ItemId, Selection, Shape, SpreadRef, Stroke};
|
||
use designcraft_geom::{Affine, BezPath, PathData, SubPath};
|
||
use serde_json::{Value, json};
|
||
|
||
use super::{CommandSpec, bad, cmd, has_selection, point_param, targets};
|
||
use crate::{Result, Session};
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
cmd!(
|
||
"object.makeCompoundPath",
|
||
"Make Compound Path",
|
||
["Object", "Paths"],
|
||
Some("Cmd+8"),
|
||
"{ids?} — one path from the selected paths (the backmost object's look); paths inside others become holes → {id}",
|
||
has_selection,
|
||
make_compound
|
||
),
|
||
cmd!(
|
||
"object.releaseCompoundPath",
|
||
"Release Compound Path",
|
||
["Object", "Paths"],
|
||
Some("Cmd+Alt+Shift+8"),
|
||
"{ids?} — each subpath becomes its own object → {ids}",
|
||
has_selection,
|
||
release_compound
|
||
),
|
||
cmd!(
|
||
"type.createOutlines",
|
||
"Create Outlines",
|
||
["Type"],
|
||
Some("Cmd+Shift+O"),
|
||
"{ids?} — text frames become compound paths of their glyphs (one per line and text colour, grouped when several, so Ungroup separates the lines) → {ids}",
|
||
has_selection,
|
||
create_outlines
|
||
),
|
||
cmd!(
|
||
"object.pathfinder",
|
||
"Pathfinder",
|
||
["Object", "Pathfinder"],
|
||
None,
|
||
"{op: add|subtract|intersect|exclude|minusBack, ids?} — combine the selected shapes into one, which keeps the frontmost object's look and content (a placed graphic stays in it); Subtract keeps the backmost → {id}",
|
||
has_selection,
|
||
pathfinder
|
||
),
|
||
cmd!(
|
||
"object.gradientFeather",
|
||
"Gradient Feather",
|
||
["Object", "Effects"],
|
||
None,
|
||
"{on?: true, radial?, angle?, start?: opacity 0–100 (100), end?: (0), from?: [x,y], to?: [x,y] (spread coords: the Gradient Feather tool's drag), ids?}",
|
||
has_selection,
|
||
gradient_feather
|
||
),
|
||
cmd!(
|
||
"path.split",
|
||
"Split Path",
|
||
[],
|
||
None,
|
||
"{id, at: [x,y] (spread coords; the nearest point on the path)} — Scissors: an open path becomes two, a closed one opens there → {ids}",
|
||
has_selection,
|
||
split_path
|
||
),
|
||
]
|
||
}
|
||
|
||
/// Item → spread transform.
|
||
fn spread_xf(d: &Document, id: ItemId) -> Option<(SpreadRef, Affine)> {
|
||
let loc = d.find(id)?;
|
||
Some((loc.spread, d.parent_xf(&loc) * d.item_at(&loc)?.xf))
|
||
}
|
||
|
||
/// Orient subpaths so those nested an odd number of times run against the others: with the
|
||
/// non-zero rule they become holes.
|
||
fn orient_holes(path: &mut PathData) {
|
||
let beziers: Vec<BezPath> = path.subpaths.iter().map(|s| PathData::single(s.clone()).to_bezpath()).collect();
|
||
let n = path.subpaths.len();
|
||
for i in 0..n {
|
||
let Some(p) = path.subpaths[i].anchors.first().map(|a| a.p) else { continue };
|
||
let depth = (0..n).filter(|&j| j != i && designcraft_geom::hit::fill_contains(&beziers[j], designcraft_geom::FillRule::NonZero, p)).count();
|
||
let sp = &mut path.subpaths[i];
|
||
let cw = sp.area() > 0.0;
|
||
if cw == (depth % 2 == 1) {
|
||
sp.reverse();
|
||
}
|
||
}
|
||
}
|
||
|
||
fn make_compound(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let ids = targets(s, p)?;
|
||
if ids.len() < 2 {
|
||
return Err(bad("object.makeCompoundPath", "select two or more paths"));
|
||
}
|
||
s.edit(|d, sel| {
|
||
// Back to front within the spread.
|
||
let (sr, _) = spread_xf(d, ids[0]).ok_or(designcraft_doc::DocError::NoItem(ids[0]))?;
|
||
let order: Vec<ItemId> = d.spread(sr).map(|sp| sp.items.iter().map(|i| i.id).filter(|i| ids.contains(i)).collect()).unwrap_or_default();
|
||
let base_id = *order.first().ok_or_else(|| bad("object.makeCompoundPath", "the paths must be on one spread"))?;
|
||
let (_, base_xf) = spread_xf(d, base_id).ok_or(designcraft_doc::DocError::NoItem(base_id))?;
|
||
let to_base = base_xf.inverse();
|
||
let mut subpaths: Vec<SubPath> = d.item(base_id).map(|i| i.path.subpaths.clone()).unwrap_or_default();
|
||
for id in order.iter().skip(1) {
|
||
let Some((_, xf)) = spread_xf(d, *id) else { continue };
|
||
let Some(it) = d.item(*id) else { continue };
|
||
if matches!(it.content, Content::Group { .. }) {
|
||
continue;
|
||
}
|
||
subpaths.extend(it.path.transformed(to_base * xf).subpaths);
|
||
d.remove_item(*id)?;
|
||
}
|
||
let it = d.item_mut(base_id).ok_or(designcraft_doc::DocError::NoItem(base_id))?;
|
||
it.path = PathData::new(subpaths);
|
||
orient_holes(&mut it.path);
|
||
it.shape = Shape::Path;
|
||
*sel = Selection::items(vec![base_id]);
|
||
Ok(json!({"id": base_id.0}))
|
||
})
|
||
}
|
||
|
||
fn pathfinder(s: &mut Session, p: &Value) -> Result<Value> {
|
||
use designcraft_geom::pathfinder::{Op, combine};
|
||
let op_s = super::str_param(p, "op").unwrap_or("add");
|
||
let op = Op::parse(op_s).ok_or_else(|| bad("object.pathfinder", format!("unknown op `{op_s}`")))?;
|
||
let ids = targets(s, p)?;
|
||
if ids.len() < 2 {
|
||
return Err(bad("object.pathfinder", "select two or more shapes"));
|
||
}
|
||
s.edit(|d, sel| {
|
||
let (sr, _) = spread_xf(d, ids[0]).ok_or(designcraft_doc::DocError::NoItem(ids[0]))?;
|
||
let order: Vec<ItemId> = d
|
||
.spread(sr)
|
||
.map(|sp| {
|
||
sp.items
|
||
.iter()
|
||
.filter(|i| ids.contains(&i.id) && !matches!(i.content, Content::Group { .. } | Content::Text(_)))
|
||
.map(|i| i.id)
|
||
.collect()
|
||
})
|
||
.unwrap_or_default();
|
||
if order.len() < 2 {
|
||
return Err(bad("object.pathfinder", "select two or more shapes on one spread (not text frames or groups)"));
|
||
}
|
||
// InDesign: the result takes the frontmost object's look and content (a photo placed over
|
||
// shapes shows through their intersection); Subtract keeps the backmost, the front cuts it.
|
||
let keep = if op == Op::Subtract { order[0] } else { order[order.len() - 1] };
|
||
let shapes: Vec<PathData> =
|
||
order.iter().filter_map(|id| spread_xf(d, *id).and_then(|(_, xf)| Some(d.item(*id)?.path.transformed(xf)))).collect();
|
||
let Some(result) = combine(op, &shapes) else { return Err(bad("object.pathfinder", "the shapes don't overlap: nothing is left")) };
|
||
let (_, kxf) = spread_xf(d, keep).ok_or(designcraft_doc::DocError::NoItem(keep))?;
|
||
for id in &order {
|
||
if *id != keep {
|
||
d.remove_item(*id)?;
|
||
}
|
||
}
|
||
let it = d.item_mut(keep).ok_or(designcraft_doc::DocError::NoItem(keep))?;
|
||
it.path = result.transformed(kxf.inverse());
|
||
orient_holes(&mut it.path);
|
||
it.shape = Shape::Path;
|
||
it.corners = Default::default();
|
||
*sel = Selection::items(vec![keep]);
|
||
Ok(json!({"id": keep.0}))
|
||
})
|
||
}
|
||
|
||
fn gradient_feather(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let ids = targets(s, p)?;
|
||
let p = p.clone();
|
||
s.edit(|d, _| {
|
||
for id in &ids {
|
||
let Some((_, xf)) = spread_xf(d, *id) else { continue };
|
||
let Some(it) = d.item_mut(*id) else { continue };
|
||
let gf = &mut it.effects.gradient_feather;
|
||
gf.on = p.get("on").and_then(Value::as_bool).unwrap_or(true);
|
||
if let Some(v) = p.get("radial").and_then(Value::as_bool) {
|
||
gf.radial = v;
|
||
}
|
||
if let Some(v) = p.get("angle").and_then(Value::as_f64) {
|
||
gf.angle = v;
|
||
gf.vector = None;
|
||
}
|
||
if let Some(v) = p.get("start").and_then(Value::as_f64) {
|
||
gf.start = (v / 100.0).clamp(0.0, 1.0) as f32;
|
||
}
|
||
if let Some(v) = p.get("end").and_then(Value::as_f64) {
|
||
gf.end = (v / 100.0).clamp(0.0, 1.0) as f32;
|
||
}
|
||
if let (Some(a), Some(b)) = (point_param(&p, "from"), point_param(&p, "to")) {
|
||
let inv = xf.inverse();
|
||
let (a, b) = (inv * a, inv * b);
|
||
if (b - a).hypot() > 1e-6 {
|
||
gf.vector = Some([a.x, a.y, b.x, b.y]);
|
||
}
|
||
}
|
||
it.effects = it.effects.clone().sanitized().map_err(|m| bad("object.gradientFeather", m))?;
|
||
}
|
||
Ok(json!({"changed": ids.len()}))
|
||
})
|
||
}
|
||
|
||
fn release_compound(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let ids = targets(s, p)?;
|
||
s.edit(|d, sel| {
|
||
let mut out = Vec::new();
|
||
for id in &ids {
|
||
let Some(it) = d.item(*id).cloned() else { continue };
|
||
if it.path.subpaths.len() < 2 || matches!(it.content, Content::Group { .. }) {
|
||
out.push(*id);
|
||
continue;
|
||
}
|
||
let (sr, _) = spread_xf(d, *id).ok_or(designcraft_doc::DocError::NoItem(*id))?;
|
||
let at = d.spread(sr).and_then(|sp| sp.items.iter().position(|i| i.id == *id));
|
||
for (k, sp) in it.path.subpaths.iter().enumerate() {
|
||
let mut part = it.clone();
|
||
part.path = PathData::single(sp.clone());
|
||
if k == 0 {
|
||
if let Some(x) = d.item_mut(*id) {
|
||
x.path = part.path;
|
||
}
|
||
out.push(*id);
|
||
continue;
|
||
}
|
||
part.id = ItemId(d.alloc());
|
||
part.content = Content::Unassigned;
|
||
out.push(part.id);
|
||
d.insert_item(sr, part, at.map(|a| a + k))?;
|
||
}
|
||
}
|
||
*sel = Selection::items(out.clone());
|
||
Ok(json!({"ids": out.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||
})
|
||
}
|
||
|
||
fn create_outlines(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let ids = targets(s, p)?;
|
||
s.edit(|d, sel| {
|
||
let db = designcraft_fonts::FontDb::global();
|
||
let mut out = Vec::new();
|
||
for id in &ids {
|
||
let Some(it) = d.item(*id).cloned() else { continue };
|
||
let Content::Text(tf) = &it.content else {
|
||
out.push(*id);
|
||
continue;
|
||
};
|
||
let cs = designcraft_compose::compose_story(d, tf.story, &Default::default());
|
||
let Some(ft) = cs.frame(*id) else { continue };
|
||
// One compound path per line and text look (fill, stroke), grouped: as in InDesign,
|
||
// Ungroup then leaves each line on its own.
|
||
let mut runs: Vec<((usize, String, f32, String, f32, f64, bool), BezPath)> = Vec::new();
|
||
for (li, l) in ft.lines.iter().enumerate() {
|
||
for g in l.glyphs.iter().filter(|g| g.visible) {
|
||
let st = &cs.styles[g.style as usize];
|
||
let outline = db.outline(&g.face, g.gid);
|
||
if outline.elements().is_empty() {
|
||
continue;
|
||
}
|
||
let skew = if st.skew != 0.0 { Affine::new([1.0, 0.0, -st.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||
let shatai = st.shatai_xf(g, l.baseline).unwrap_or(Affine::IDENTITY);
|
||
let a = shatai * Affine::translate((g.x, l.baseline + g.y)) * skew * Affine::scale_non_uniform(g.sx, g.sy);
|
||
let key = (li, st.fill.clone(), st.fill_tint, st.stroke.clone(), st.stroke_tint, st.stroke_weight, st.stroke_behind_fill);
|
||
let k = match runs.iter().position(|r| r.0 == key) {
|
||
Some(k) => k,
|
||
None => {
|
||
runs.push((key, BezPath::new()));
|
||
runs.len() - 1
|
||
}
|
||
};
|
||
let bp = &mut runs[k].1;
|
||
bp.extend((a * outline.as_ref().clone()).elements().iter().copied());
|
||
}
|
||
}
|
||
if runs.is_empty() {
|
||
continue;
|
||
}
|
||
let (sr, _) = spread_xf(d, *id).ok_or(designcraft_doc::DocError::NoItem(*id))?;
|
||
let at = d.spread(sr).and_then(|sp| sp.items.iter().position(|i| i.id == *id));
|
||
let text_xf = d.text_xf(&it);
|
||
let make = |d: &mut Document, ((_, fill, ft, stroke, stt, sw, behind), bp): ((usize, String, f32, String, f32, f64, bool), BezPath)| {
|
||
let mut o = Item::new(ItemId(d.alloc()), it.layer, Shape::Path, PathData::from_bezpath(&bp));
|
||
o.xf = text_xf;
|
||
o.fill = Fill { swatch: fill, tint: ft, ..Fill::none() };
|
||
o.stroke = if stroke == designcraft_color::swatch::NONE {
|
||
Stroke::none()
|
||
} else {
|
||
// A stroke behind the fill lies wholly outside the letters, as an outside-aligned stroke.
|
||
let align = if behind { designcraft_doc::StrokeAlign::Outside } else { designcraft_doc::StrokeAlign::Center };
|
||
Stroke { swatch: stroke, tint: stt, weight: sw, align, ..Stroke::default() }
|
||
};
|
||
o
|
||
};
|
||
let mut parts: Vec<Item> = runs.into_iter().map(|r| make(d, r)).collect();
|
||
d.remove_item(*id)?;
|
||
let new = if parts.len() == 1
|
||
&& let Some(one) = parts.pop()
|
||
{
|
||
one
|
||
} else {
|
||
let mut g = Item::new(ItemId(d.alloc()), it.layer, Shape::Group, PathData::default());
|
||
for p in &mut parts {
|
||
p.xf = Affine::IDENTITY;
|
||
}
|
||
g.xf = it.xf;
|
||
g.content = Content::Group { items: parts.into_iter().map(Arc::new).collect() };
|
||
g
|
||
};
|
||
out.push(new.id);
|
||
d.insert_item(sr, new, at)?;
|
||
}
|
||
*sel = Selection::items(out.clone());
|
||
Ok(json!({"ids": out.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||
})
|
||
}
|
||
|
||
fn split_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let id = super::id_param(p, "id").ok_or_else(|| bad("path.split", "missing id"))?;
|
||
let at = point_param(p, "at").ok_or_else(|| bad("path.split", "missing at"))?;
|
||
s.edit(|d, sel| {
|
||
super::object::path_editable(d, id, "path.split")?;
|
||
let (sr, xf) = spread_xf(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||
let it = d.item(id).cloned().ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||
if !matches!(it.content, Content::Unassigned) {
|
||
return Err(bad("path.split", "only empty paths and frames can be cut"));
|
||
}
|
||
let (si, seg, t, _, _) = it.path.nearest(xf.inverse() * at).ok_or_else(|| bad("path.split", "the object has no path"))?;
|
||
let mut sp = it.path.subpaths[si].clone();
|
||
// Insert an anchor at the cut unless it's (nearly) on one.
|
||
let ai = if t < 1e-3 {
|
||
seg
|
||
} else if t > 1.0 - 1e-3 {
|
||
(seg + 1) % sp.anchors.len()
|
||
} else {
|
||
sp.insert_anchor(seg, t)
|
||
};
|
||
let n = sp.anchors.len();
|
||
let mut pieces: Vec<SubPath> = Vec::new();
|
||
if sp.closed {
|
||
// Open at the cut: it becomes both ends.
|
||
let mut a: Vec<_> = sp.anchors[ai..].iter().chain(sp.anchors[..ai].iter()).copied().collect();
|
||
a.push(sp.anchors[ai]);
|
||
if let Some(f) = a.first_mut() {
|
||
f.h_in = f.p;
|
||
}
|
||
if let Some(l) = a.last_mut() {
|
||
l.h_out = l.p;
|
||
}
|
||
pieces.push(SubPath::new(a, false));
|
||
} else {
|
||
if ai == 0 || ai >= n - 1 {
|
||
return Err(bad("path.split", "that's an end point"));
|
||
}
|
||
let (mut a, mut b) = (sp.anchors[..=ai].to_vec(), sp.anchors[ai..].to_vec());
|
||
if let Some(l) = a.last_mut() {
|
||
l.h_out = l.p;
|
||
}
|
||
if let Some(f) = b.first_mut() {
|
||
f.h_in = f.p;
|
||
}
|
||
pieces.push(SubPath::new(a, false));
|
||
pieces.push(SubPath::new(b, false));
|
||
}
|
||
sp.anchors.clear();
|
||
let mut ids = vec![id];
|
||
let first = pieces.remove(0);
|
||
let it_m = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||
it_m.path.subpaths[si] = first;
|
||
it_m.shape = if it_m.shape == Shape::GraphicLine { Shape::GraphicLine } else { Shape::Path };
|
||
for piece in pieces {
|
||
let mut other = it.clone();
|
||
other.id = ItemId(d.alloc());
|
||
other.path = PathData::single(piece);
|
||
other.shape = Shape::Path;
|
||
ids.push(other.id);
|
||
d.insert_item(sr, other, None)?;
|
||
}
|
||
*sel = Selection::items(ids.clone());
|
||
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn compound_paths_make_holes_and_release() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let a = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap();
|
||
let b = s.execute("frame.create", &json!({"rect": [150, 150, 250, 250]})).unwrap()["id"].as_u64().unwrap();
|
||
s.execute("object.fill", &json!({"ids": [a], "swatch": "[Black]"})).unwrap();
|
||
let r = s.execute("object.makeCompoundPath", &json!({"ids": [a, b]})).unwrap();
|
||
assert_eq!(r["id"], a);
|
||
let d = s.doc().unwrap().doc.clone();
|
||
assert!(d.item(ItemId(b)).is_none());
|
||
let it = d.item(ItemId(a)).unwrap();
|
||
assert_eq!(it.path.subpaths.len(), 2);
|
||
// The inner square is a hole: the centre stays white, the ring is black.
|
||
let mut rr = designcraft_render::Renderer::new();
|
||
rr.threads = 0;
|
||
let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap();
|
||
assert!(img.pixel(200, 200)[0] > 200, "hole: {:?}", img.pixel(200, 200));
|
||
assert!(img.pixel(120, 200)[0] < 60, "ring: {:?}", img.pixel(120, 200));
|
||
let r = s.execute("object.releaseCompoundPath", &json!({})).unwrap();
|
||
assert_eq!(r["ids"].as_array().unwrap().len(), 2);
|
||
s.doc().unwrap().doc.check().unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn pathfinder_subtracts_front_from_back() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let a = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap();
|
||
let b = s.execute("frame.create", &json!({"rect": [200, 100, 400, 300], "shape": "ellipse"})).unwrap()["id"].as_u64().unwrap();
|
||
s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
|
||
let r = s.execute("object.pathfinder", &json!({"op": "subtract"})).unwrap();
|
||
assert_eq!(r["id"], a);
|
||
let d = s.doc().unwrap().doc.clone();
|
||
assert!(d.item(ItemId(b)).is_none());
|
||
let bb = d.item(ItemId(a)).unwrap().bounds();
|
||
assert!((bb.x1 - 300.0).abs() < 0.5 && (bb.x0 - 100.0).abs() < 0.5, "{bb:?}");
|
||
assert!(s.execute("object.pathfinder", &json!({"op": "nope"})).is_err());
|
||
}
|
||
|
||
/// The result keeps the frontmost object's look (Subtract: the backmost's), as InDesign's.
|
||
#[test]
|
||
fn pathfinder_keeps_the_frontmost_object() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
s.execute("swatch.create", &json!({"name": "Red", "color": [255, 0, 0]})).unwrap();
|
||
for op in ["add", "intersect", "exclude", "minusBack"] {
|
||
let a = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap();
|
||
let b = s.execute("frame.create", &json!({"rect": [200, 100, 400, 300]})).unwrap()["id"].as_u64().unwrap();
|
||
s.execute("object.fill", &json!({"ids": [a], "swatch": "[Black]"})).unwrap();
|
||
s.execute("object.fill", &json!({"ids": [b], "swatch": "Red"})).unwrap();
|
||
let r = s.execute("object.pathfinder", &json!({"op": op, "ids": [a, b]})).unwrap();
|
||
assert_eq!(r["id"], b, "{op}: the front object stays");
|
||
let d = s.doc().unwrap().doc.clone();
|
||
assert!(d.item(ItemId(a)).is_none(), "{op}");
|
||
assert_eq!(d.item(ItemId(b)).unwrap().fill.swatch, "Red", "{op}");
|
||
s.execute("edit.clear", &json!({"ids": [b]})).unwrap();
|
||
}
|
||
}
|
||
|
||
/// A photo placed over strips and intersected with them shows through the strips (it was
|
||
/// dropped: the black strips were kept).
|
||
#[test]
|
||
fn pathfinder_intersect_keeps_a_placed_photo_in_front() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let a = s.execute("frame.create", &json!({"rect": [100, 100, 300, 140]})).unwrap()["id"].as_u64().unwrap();
|
||
let b = s.execute("frame.create", &json!({"rect": [100, 160, 300, 200]})).unwrap()["id"].as_u64().unwrap();
|
||
s.execute("object.fill", &json!({"ids": [a, b], "swatch": "[Black]"})).unwrap();
|
||
let strips = s.execute("object.pathfinder", &json!({"op": "add", "ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
|
||
let png = designcraft_render::Rendered { width: 20, height: 20, pixels: [255u8, 255, 255, 255].repeat(400) }.to_png();
|
||
let photo = s.execute("frame.create", &json!({"rect": [80, 80, 320, 220], "content": "graphic"})).unwrap()["id"].as_u64().unwrap();
|
||
s.execute("file.place", &json!({"base64": super::super::base64_encode(&png), "name": "white.png", "frame": photo})).unwrap();
|
||
s.execute("object.fit", &json!({"mode": "fillProportionally", "ids": [photo]})).unwrap();
|
||
let r = s.execute("object.pathfinder", &json!({"op": "intersect", "ids": [strips, photo]})).unwrap();
|
||
assert_eq!(r["id"], photo);
|
||
let d = s.doc().unwrap().doc.clone();
|
||
assert!(d.item(ItemId(strips)).is_none());
|
||
let it = d.item(ItemId(photo)).unwrap();
|
||
assert!(matches!(it.content, Content::Graphic(_)), "the photo stays in the result");
|
||
assert_eq!(it.path.subpaths.len(), 2, "cut to the two strips");
|
||
let b = it.bounds();
|
||
assert!((b.x0 - 100.0).abs() < 0.5 && (b.y0 - 100.0).abs() < 0.5 && (b.x1 - 300.0).abs() < 0.5 && (b.y1 - 200.0).abs() < 0.5, "{b:?}");
|
||
let mut rr = designcraft_render::Renderer::new();
|
||
rr.threads = 0;
|
||
let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap();
|
||
assert!(img.pixel(200, 120)[0] > 230, "the photo (white) shows in a strip: {:?}", img.pixel(200, 120));
|
||
}
|
||
|
||
#[test]
|
||
fn gradient_feather_fades_on_screen_and_in_pdf() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let id = s.execute("frame.create", &json!({"rect": [100, 100, 300, 200]})).unwrap()["id"].as_u64().unwrap();
|
||
s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap();
|
||
s.execute("object.gradientFeather", &json!({"ids": [id], "from": [100, 150], "to": [300, 150]})).unwrap();
|
||
let d = s.doc().unwrap().doc.clone();
|
||
let mut rr = designcraft_render::Renderer::new();
|
||
rr.threads = 0;
|
||
let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap();
|
||
let (l, m, r) = (img.pixel(105, 150)[0], img.pixel(200, 150)[0], img.pixel(295, 150)[0]);
|
||
assert!(l < 60 && m > l + 40 && r > 220, "fades left to right: {l} {m} {r}");
|
||
let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap();
|
||
let t = String::from_utf8_lossy(&pdf);
|
||
assert!(t.contains("/SMask") || t.contains("/Luminosity"), "a soft mask");
|
||
}
|
||
|
||
#[test]
|
||
fn a_groups_gradient_feather_fades_it_on_screen_and_in_pdf() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let mut ids = vec![];
|
||
for rect in [[100, 100, 200, 200], [200, 100, 300, 200]] {
|
||
let id = s.execute("frame.create", &json!({"rect": rect})).unwrap()["id"].as_u64().unwrap();
|
||
s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap();
|
||
ids.push(id);
|
||
}
|
||
let g = s.execute("object.group", &json!({"ids": ids})).unwrap()["id"].as_u64().unwrap();
|
||
s.execute("transform.move", &json!({"ids": [g], "dx": 20, "dy": 30})).unwrap();
|
||
// The Gradient Feather tool's drag, then by angle (a group has no path of its own: the
|
||
// gradient spans its objects). The gradient still follows the translated group even
|
||
// though the mask geometry covers the whole output.
|
||
for p in [json!({"ids": [g], "from": [120, 180], "to": [320, 180]}), json!({"ids": [g], "angle": 0})] {
|
||
s.execute("object.gradientFeather", &p).unwrap();
|
||
let d = s.doc().unwrap().doc.clone();
|
||
let mut rr = designcraft_render::Renderer::new();
|
||
rr.threads = 0;
|
||
let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap();
|
||
let (l, m, r) = (img.pixel(125, 180)[0], img.pixel(220, 180)[0], img.pixel(315, 180)[0]);
|
||
assert!(l < 60 && m > l + 40 && r > 220, "{p}: fades left to right on screen: {l} {m} {r}");
|
||
let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap();
|
||
let pm = designcraft_render::render_pdf_page(&pdf, 0, 400).unwrap();
|
||
// Over white paper.
|
||
let at = |x: usize, y: usize| {
|
||
let c = pm.data()[y * pm.width() as usize + x];
|
||
c.r as u32 + 255 - c.a as u32
|
||
};
|
||
let (l, m, r) = (at(125, 180), at(220, 180), at(315, 180));
|
||
assert!(l < 60 && m > l + 40 && r > 220, "{p}: fades left to right in PDF: {l} {m} {r}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn group_gradient_feather_keeps_distant_shadows_and_thick_strokes() {
|
||
for shadow in [false, true] {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||
let mut ids = vec![];
|
||
for rect in [[120, 120, 170, 170], [175, 120, 225, 170]] {
|
||
let id = s.execute("frame.create", &json!({"rect": rect})).unwrap()["id"].as_u64().unwrap();
|
||
s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap();
|
||
if shadow {
|
||
s.execute("object.dropShadow", &json!({"ids": [id], "on": true, "distance": 180, "angle": 90, "size": 0, "opacity": 1})).unwrap();
|
||
} else {
|
||
s.execute("object.stroke", &json!({"ids": [id], "swatch": "[Black]", "weight": 180})).unwrap();
|
||
}
|
||
ids.push(id);
|
||
}
|
||
let g = s.execute("object.group", &json!({"ids": ids})).unwrap()["id"].as_u64().unwrap();
|
||
// Translate the group so mask geometry and gradient paint exercise distinct spaces.
|
||
s.execute("transform.move", &json!({"ids": [g], "dx": 30, "dy": 20})).unwrap();
|
||
let point = if shadow { (175, 345) } else { (175, 60) };
|
||
let sample = |s: &Session| {
|
||
let d = &s.doc().unwrap().doc;
|
||
let mut rr = designcraft_render::Renderer::new();
|
||
rr.threads = 0;
|
||
// Zoomed viewport: the mask must extend over every device pixel.
|
||
let img = rr.render_page(d, &s.cache, 0, 2.0, false, &Default::default()).unwrap();
|
||
let screen = img.pixel(point.0 * 2, point.1 * 2)[0];
|
||
let pdf = designcraft_pdf::export_pdf(d, &s.cache, &Default::default()).unwrap();
|
||
let pm = designcraft_render::render_pdf_page(&pdf, 0, 400).unwrap();
|
||
let c = pm.data()[point.1 as usize * pm.width() as usize + point.0 as usize];
|
||
[f32::from(screen), c.r as f32 + 255.0 - c.a as f32]
|
||
};
|
||
let before = sample(&s);
|
||
// The sample lies past either end of the gradient, and outside the old fixed padding.
|
||
s.execute("object.gradientFeather", &json!({"ids": [g], "from": [130, 120], "to": [130, 220], "start": 40, "end": 80})).unwrap();
|
||
let after = sample(&s);
|
||
let opacity = if shadow { 0.8 } else { 0.4 };
|
||
for (kind, (before, after)) in ["screen", "PDF"].into_iter().zip(before.into_iter().zip(after)) {
|
||
assert!(before < 120.0, "{kind}, shadow={shadow}: sample contains the effect before feathering: {before}");
|
||
let expected = 255.0 - (255.0 - before) * opacity;
|
||
assert!(
|
||
(after - expected).abs() < 16.0,
|
||
"{kind}, shadow={shadow}: end opacity continues past geometry: {after}, expected {expected}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn create_outlines_turns_text_into_paths() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Outline"})).unwrap();
|
||
let id = r["id"].as_u64().unwrap();
|
||
s.execute("selection.set", &json!({"ids": [id]})).unwrap();
|
||
let r = s.execute("type.createOutlines", &json!({})).unwrap();
|
||
let new = ItemId(r["ids"][0].as_u64().unwrap());
|
||
let d = s.doc().unwrap().doc.clone();
|
||
assert!(d.item(ItemId(id)).is_none());
|
||
let it = d.item(new).unwrap();
|
||
assert_eq!(it.shape, Shape::Path);
|
||
assert!(it.path.subpaths.len() >= 7, "a contour or more per letter: {}", it.path.subpaths.len());
|
||
let b = it.bounds();
|
||
assert!(b.x0 >= 72.0 && b.y0 >= 72.0 && b.x1 < 400.0, "{b:?}");
|
||
assert!(d.stories.is_empty() || !d.stories.values().any(|st| st.frames.contains(&ItemId(id))));
|
||
}
|
||
|
||
#[test]
|
||
fn outlines_of_several_lines_ungroup_line_by_line() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "GOOD\nMORNING"})).unwrap();
|
||
let id = r["id"].as_u64().unwrap();
|
||
s.execute("selection.set", &json!({"ids": [id]})).unwrap();
|
||
let g = s.execute("type.createOutlines", &json!({})).unwrap()["ids"][0].as_u64().unwrap();
|
||
let d = s.doc().unwrap().doc.clone();
|
||
let group = d.item(ItemId(g)).unwrap();
|
||
assert!(group.is_group(), "a group of the lines");
|
||
let lines: Vec<_> = group.children().iter().map(|c| c.bounds()).collect();
|
||
assert_eq!(lines.len(), 2, "one compound path per line");
|
||
assert!(lines[0].y1 <= lines[1].y0 + 1.0, "GOOD above MORNING: {lines:?}");
|
||
// Ungroup: each line on its own, to space them and make one compound path again.
|
||
s.execute("object.ungroup", &json!({})).unwrap();
|
||
let sel = s.doc().unwrap().selection.items.clone();
|
||
assert_eq!(sel.len(), 2);
|
||
let d = s.doc().unwrap().doc.clone();
|
||
assert!(sel.iter().all(|i| d.item(*i).is_some_and(|it| it.shape == Shape::Path && it.path.subpaths.len() >= 4)));
|
||
s.execute("object.makeCompoundPath", &json!({})).unwrap();
|
||
assert_eq!(s.doc().unwrap().selection.items.len(), 1);
|
||
s.doc().unwrap().doc.check().unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn scissors_split_open_and_closed_paths() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let line = s.execute("line.create", &json!({"a": [100, 100], "b": [300, 100]})).unwrap()["id"].as_u64().unwrap();
|
||
let r = s.execute("path.split", &json!({"id": line, "at": [200, 101]})).unwrap();
|
||
assert_eq!(r["ids"].as_array().unwrap().len(), 2);
|
||
let d = s.doc().unwrap().doc.clone();
|
||
let w: Vec<f64> =
|
||
r["ids"].as_array().unwrap().iter().map(|i| d.item(ItemId(i.as_u64().unwrap())).unwrap().bounds().width().round()).collect();
|
||
assert_eq!(w, [100.0, 100.0]);
|
||
let rect = s.execute("frame.create", &json!({"rect": [100, 200, 200, 300]})).unwrap()["id"].as_u64().unwrap();
|
||
let r = s.execute("path.split", &json!({"id": rect, "at": [150, 200]})).unwrap();
|
||
assert_eq!(r["ids"].as_array().unwrap().len(), 1);
|
||
let d = s.doc().unwrap().doc.clone();
|
||
let it = d.item(ItemId(rect)).unwrap();
|
||
assert!(!it.path.is_closed());
|
||
assert_eq!(it.path.anchor_count(), 6);
|
||
}
|
||
}
|