mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-11 02:50:22 -06:00
Pages: parents and alternate layouts; export and print layouts and versions (#1342)
* Pages: more parents and alternate layouts as columns in the Pages panel
Parents
- layout.parents.duplicate copies a parent spread (pages and items) under the next free prefix.
- layout.parents.options also renames the prefix (1-4 characters, no "-") and the name; basedOn is now optional.
- layout.parents.new validates prefix and name and takes basedOn.
- Pages panel: a "+ New Parent" row adds B, C… in one click. The parents' context menu and the panel menu have New Parent…, Duplicate Parent Spread, Parent Options… and Delete Parent, named after the selected parent, or a submenu of the parents when none is selected. A new Parent Options dialog has Prefix, Name and Based on Parent.
Alternate layouts
- Section.layout marks the section that starts a layout; Document::layouts() lists them.
- layout.createAlternate copies one layout (from), not every page; new layout.alternate.duplicate, .delete and .options (rename, resize).
- Each layout starts a spread of its own; sections now follow their pages when pages are inserted or deleted.
- Pages panel: with more than one layout, each is a column side by side with a header (name, size, ▾ menu: create, duplicate, options, delete) and a + column to add one.
Tests cover the commands' happy and error paths, undo, serde, and the panel's menus and columns.
* Pages: address review of parents and alternate layouts
- Deleting a layout's first page keeps the layout's name when another section lands on the same page.
- A parent's full label (prefix-name) must be unique; parents may still share a prefix.
- Alternate layout names round-trip through IDML (Section AlternateLayout / AlternateLayoutLength).
- The Pages panel's selected parent is per document and no longer saved in ui.json.
- Layout ▸ Parent Options… and Alternate Layout Options… open the Pages panel's dialogs.
- The Pages panel computes the layouts once per frame.
* Export: layouts and versions in one export, with presets; print chosen layouts
- file.exportBatch writes the chosen alternate layouts (a file per layout, or all in one PDF) in several versions at once: PDF with any marks, bleed and standard, PNG or JPEG at a resolution. Each version goes in a folder of its own, and file names come from a template ({document}, {layout}, {version}, {date}). Names are made safe and unique. dryRun lists the files; without a dir the files come back as base64 (web).
- Export presets: file.exportPresets, file.saveExportPreset, file.deleteExportPreset, kept in prefs.json. Built-ins: Print + Screen, Press PDF per Layout, Images per Layout, All Layouts in One PDF.
- file.print takes layouts and separateJobs (a job per layout, titled after it).
- File ▸ Export Layouts and Versions… dialog: preset, layouts, versions (add from templates, duplicate, remove, quick PDF choices or custom marks), name template, and a live list of the files it will write. It opens from a layout column's ▾ menu as "Export Layout…". The desktop asks for a folder; the web downloads each file.
- The Print dialog prints chosen layouts, together or separately; the Export PDF dialog fills the page range from a layout.
- crate::io::create_dir_all keeps folder creation inside the automation write root.
* Export: address review of layouts and versions
- An exported image is capped at 80 million pixels, the limit EPUB page images already use.
- The Print dialog stays open and says why when no layout is chosen.
- Clicking a layout column's header leaves Edit Parents before going to the layout's first page.
* Pages, Export: address maintainer review of layouts and versions
- Sections (and the layouts they start) follow a moved page, as they do inserted and deleted
ones: moving a page across a layout boundary no longer moves the other pages between layouts.
Delete and move share `settle_sections`.
- Alternate Layout Options resizes only pages whose size changes: renaming a layout no longer
forces every page to the first page's size.
- Export file and folder names that are Windows device names (CON, NUL, COM1…) get a leading `_`.
- An export without `dir` refuses past 512 MB of files instead of building them all in memory.
- A file that fails partway through an export to a folder says how many were already written.
- Parent Options and Print keep the dialog open, with what was typed and why, when the engine
refuses.
- A duplicated layout's default name stays within the 100-character limit.
- rustfmt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Pages: Alternate Layout Options sends the size only when it changed
The dialog starts at the layout's first page's size, so the engine couldn't tell a rename from a
resize to that size. The dialog now leaves out a width or height still at its starting value, and
`layout.alternate.options` resizes every page of the layout when a size is given, as before.
The test moves to the UI, where the size was coming from.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
FredApps
parent
a794037761
commit
4f3bb5340e
+3
-1
@@ -4,7 +4,7 @@
|
||||
- 2026-10-23: placed-artwork layer options survive IDML and native round trips per placement, independently of shared assets. Unsupported visibility overrides are explicit import/preflight/PDF warnings; format 4 prevents older native readers from silently discarding them. Rendering parity is still open; see [graphic-layer-options.md](docs/graphic-layer-options.md).
|
||||
- FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage.
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-04 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-04 · **Change:** minor (Pages panel parents and alternate layouts; File ▸ Export Layouts and Versions; placed graphic layer-option preservation and explicit rendering warnings; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026
|
||||
- AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it.
|
||||
|
||||
- AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md.
|
||||
@@ -435,6 +435,8 @@ Newest first.
|
||||
- **2026-10-24** · Quick Apply and the application bar's search leave out the interface commands that failed or reset a setting when chosen there: those that need a workspace, a command and shortcut, a menu item, a panel, a window edge, an app page, a recent file or a swatch they can't give them, a Contextual Task Bar place, and Interface Color Theme, UI Scaling, Reset Shortcuts, the flattener preset, Screen Mode and Zoom To, which without a value switched to the dark theme, set 100%, cleared every custom shortcut or the preset, or went to Normal mode or 100% zoom. Each stays in the menu, panel or dialog that gives it the value. No readiness percentage change.
|
||||
- **2026-10-24** · EPUB Export Options › Split Document, a dialog before File › Export EPUB… saves (`document.epubSplit`, stored with the document): off, a single paragraph style, or based on paragraph style export tags, with Paragraph Style Options › Export Tagging's Split Document flag marking those styles. A reflowable EPUB is one content file unless Split Document is on; the split before each top-level h1 from #791 is gone. A split before a list item or preformatted line no longer leaves an unclosed list or block in a content file. IDML import and export keep the setting and the flag, and Load Styles the flag. No readiness percentage change.
|
||||
- **2026-10-24** · Linux file managers show a `.designcraft` file's first page instead of a blank icon: the packages install a freedesktop thumbnailer (`share/thumbnailers/ai.storyteller.designcraft.thumbnailer`) that runs `designcraft-cli thumbnail FILE OUT.png SIZE`, which writes the `preview.png` saving already puts in the file, fitted to the size asked for and never enlarged. A file without a preview fails, so the file manager keeps the file type's icon (#1782). No readiness percentage change.
|
||||
- **2026-10-20** · File ▸ Export Layouts and Versions (`file.exportBatch`): export alternate layouts one by one, all in one PDF or as a PDF each, in several versions at once (press PDF with marks and bleed, PDF with bleed only, screen PDF, PNG, JPEG), each in its own folder and named from the layouts; the dialog lists the files before writing and saves the setup as an export preset. Print can print chosen layouts, together or as a job each. No readiness percentage change.
|
||||
- **2026-10-19** · Parents and alternate layouts in the Pages panel. Add parents (B, C…) with one click, duplicate them (`layout.parents.duplicate`) and rename their prefix and name in Parent Options (`layout.parents.options {prefix, name}`), from the parents' context menu or the panel menu. Alternate layouts show as columns side by side, each with a menu to create, duplicate (`layout.alternate.duplicate`), rename or resize (`layout.alternate.options`) and delete (`layout.alternate.delete`) it. Sections now follow their pages when pages are inserted or deleted. No readiness percentage change.
|
||||
- **2026-10-18** · Placing an image or media without coordinates, or clicking the loaded place cursor, no longer turns a line into a frame: lines and paths whose points all lie on one line are left as they are and the content gets a new frame. Frames, and open paths that enclose an area, take the content as before. No readiness percentage change.
|
||||
- **2026-10-18** · Repeated placed-PDF layer variants are prepared once per asset and hidden-name set, with recovery warnings retained. All-visible named layers keep their original source bytes, including PDFs whose cross-reference tables the reader repairs, in plain, PDF/X-4, booklet and book-merge output. Layer changes that require writing still reject an unusable cross-reference table with a file-named damage error; the first placement error is retained (#906). No readiness percentage change.
|
||||
- **2026-10-18** · Placed PDF optional-content defaults and per-frame overrides survive plain and PDF/X-4 vector export, including repeated placements, booklet output and book merging (#906). Source usage-application arrays are accepted; groups without a catalog configuration stay visible. Missing or unreadable default configurations and unsupported base states use all-ON defaults with a file-named export warning, retaining explicit frame overrides. Readable groups omitted from the catalog list are added with a distinct file-named warning, preserving their default ON/OFF states. Concurrent recovery warnings are combined. Malformed ON data is recoverable when a known ON base and empty OFF list prove it cannot hide artwork; missing display names are generated without changing known visibility when no override needs name matching. Contradictory states, unresolved references and ambiguous name overrides still fail export. Print-usage adjustments and IDML layer overrides remain follow-ups. No readiness percentage change.
|
||||
|
||||
+95
-1
@@ -148,6 +148,7 @@ impl Document {
|
||||
prefix: String::new(),
|
||||
marker: String::new(),
|
||||
include_prefix: false,
|
||||
layout: None,
|
||||
}],
|
||||
assets: BTreeMap::new(),
|
||||
hyperlinks: vec![],
|
||||
@@ -259,6 +260,31 @@ impl Document {
|
||||
id
|
||||
}
|
||||
|
||||
/// Add an empty copy of parent `id` after the last parent: the same pages (size, margins, columns,
|
||||
/// guides) and base parent, fresh ids, prefix `prefix`. Returns the copy's id and index; the
|
||||
/// caller copies the items.
|
||||
pub fn duplicate_parent_shell(&mut self, id: SpreadId, prefix: &str) -> Result<(SpreadId, usize)> {
|
||||
let src = self.parent_index(id).and_then(|i| self.parents.get(i)).ok_or_else(|| DocError::Invalid(format!("no parent {id}")))?;
|
||||
let mut sp = Spread::clone(src);
|
||||
sp.items.clear();
|
||||
for pg in &mut sp.pages {
|
||||
pg.overridden.clear();
|
||||
}
|
||||
sp.id = SpreadId(self.alloc());
|
||||
for k in 0..sp.pages.len() {
|
||||
let pid = PageId(self.alloc());
|
||||
if let Some(pg) = sp.pages.get_mut(k) {
|
||||
pg.id = pid;
|
||||
}
|
||||
}
|
||||
if let Some(info) = sp.parent.as_mut() {
|
||||
info.prefix = prefix.into();
|
||||
}
|
||||
let new = sp.id;
|
||||
self.parents.push(Arc::new(sp));
|
||||
Ok((new, self.parents.len().saturating_sub(1)))
|
||||
}
|
||||
|
||||
/// Delete a parent spread and unapply it wherever it is used. Local overrides survive as
|
||||
/// ordinary items; the deleted parent's own items leave their text threads normally.
|
||||
pub fn delete_parent(&mut self, id: SpreadId) -> Result<()> {
|
||||
@@ -340,6 +366,9 @@ impl Document {
|
||||
/// a left page moves it to the right and the pages after it follow.
|
||||
pub fn repaginate(&mut self) {
|
||||
let odd: Vec<bool> = (0..self.page_count()).map(|i| self.page_number(i) % 2 == 1).collect();
|
||||
// A layout starts a spread of its own.
|
||||
let layout_starts: HashSet<usize> = self.sections.iter().filter(|s| s.layout.is_some() && s.start > 0).map(|s| s.start).collect();
|
||||
let mut breaks: Vec<bool> = Vec::new();
|
||||
// Collect pages with their items (items in page-relative coordinates) and their numbers' parity.
|
||||
let mut pages: Vec<(Page, Vec<Arc<Item>>)> = Vec::new();
|
||||
let mut parity: Vec<bool> = Vec::new();
|
||||
@@ -355,6 +384,7 @@ impl Document {
|
||||
}
|
||||
for p in &sp.pages {
|
||||
parity.push(odd.get(abs).copied().unwrap_or(abs % 2 == 0));
|
||||
breaks.push(layout_starts.contains(&abs));
|
||||
was_left.insert(p.id, p.side == PageSide::Left);
|
||||
abs += 1;
|
||||
}
|
||||
@@ -419,7 +449,8 @@ impl Document {
|
||||
// A right page closes its spread, so two right pages in a row (a section restart
|
||||
// at an even position) never share one.
|
||||
let after_ending = cur.last().is_some_and(|(q, _)| q.side != starts);
|
||||
if (p.side == starts || after_ending) && !cur.is_empty() {
|
||||
let new_layout = breaks.get(i).copied().unwrap_or(false);
|
||||
if (p.side == starts || after_ending || new_layout) && !cur.is_empty() {
|
||||
let id = SpreadId(self.alloc());
|
||||
flush(&mut cur, &mut spreads, id);
|
||||
}
|
||||
@@ -473,6 +504,13 @@ impl Document {
|
||||
}
|
||||
let n = self.page_count();
|
||||
let at = after.map_or(0, |a| a.saturating_add(1).min(n));
|
||||
// Sections (and the layouts they start) stay with their first page: the ones after the
|
||||
// insertion point move along. Pages inserted after a layout's last page join that layout.
|
||||
for sec in &mut self.sections {
|
||||
if sec.start >= at && sec.start > 0 {
|
||||
sec.start = sec.start.saturating_add(count);
|
||||
}
|
||||
}
|
||||
let template = self.page(at.saturating_sub(1).min(n.saturating_sub(1))).cloned();
|
||||
let (cols, gutter, margins) =
|
||||
template.as_ref().map(|t| (t.columns.count, t.columns.gutter, t.margins)).unwrap_or((1, 12.0, Margins::uniform(36.0)));
|
||||
@@ -575,6 +613,18 @@ impl Document {
|
||||
sp.relayout();
|
||||
}
|
||||
self.spreads.retain(|s| !s.pages.is_empty());
|
||||
// Sections follow their pages: a section whose first page is deleted starts at the next
|
||||
// page left, unless another section already starts there (or no page is left).
|
||||
let mut gone: Vec<usize> = pages.to_vec();
|
||||
gone.sort_unstable();
|
||||
gone.dedup();
|
||||
let left = self.page_count();
|
||||
let mut sections = std::mem::take(&mut self.sections);
|
||||
for sec in &mut sections {
|
||||
let before = gone.partition_point(|g| *g < sec.start);
|
||||
sec.start = sec.start.saturating_sub(before);
|
||||
}
|
||||
self.sections = settle_sections(sections, left);
|
||||
self.repaginate();
|
||||
Ok(())
|
||||
}
|
||||
@@ -594,6 +644,23 @@ impl Document {
|
||||
self.repaginate();
|
||||
let sp = self.spreads.remove(from);
|
||||
self.spreads.insert(to, sp);
|
||||
// Sections (and the layouts they start) keep their first page wherever it moved; one whose
|
||||
// first page moves away starts at its next page, as when that page is deleted.
|
||||
let moved = |i: usize| {
|
||||
if from < to && i > from && i <= to {
|
||||
i.saturating_sub(1)
|
||||
} else if to < from && i >= to && i < from {
|
||||
i.saturating_add(1)
|
||||
} else {
|
||||
i
|
||||
}
|
||||
};
|
||||
let mut sections = std::mem::take(&mut self.sections);
|
||||
for sec in sections.iter_mut().filter(|s| s.start > 0) {
|
||||
sec.start = if sec.start == from { moved(from.saturating_add(1)) } else { moved(sec.start) };
|
||||
}
|
||||
sections.sort_by_key(|s| s.start);
|
||||
self.sections = settle_sections(sections, n);
|
||||
self.settings.facing_pages = facing;
|
||||
self.repaginate();
|
||||
Ok(())
|
||||
@@ -865,6 +932,33 @@ impl Document {
|
||||
/// Deepest `based_on` chain followed.
|
||||
pub const MAX_PARENT_DEPTH: usize = 64;
|
||||
|
||||
/// Sections after their starts moved (pages deleted or moved), in page order: of sections that now
|
||||
/// share a start the last is kept, those past the last of `left` pages go, and when the first
|
||||
/// layout lost its pages the next one begins the document.
|
||||
fn settle_sections(sections: Vec<Section>, left: usize) -> Vec<Section> {
|
||||
let mut kept: Vec<Section> = Vec::with_capacity(sections.len());
|
||||
for sec in sections.into_iter().filter(|s| s.start < left) {
|
||||
if let Some(last) = kept.last_mut().filter(|l| l.start == sec.start) {
|
||||
// A layout's name outlives its first page: it moves onto the section kept.
|
||||
let layout = last.layout.take();
|
||||
*last = sec;
|
||||
if last.layout.is_none() {
|
||||
last.layout = layout;
|
||||
}
|
||||
} else {
|
||||
kept.push(sec);
|
||||
}
|
||||
}
|
||||
if let Some(first) = kept.first_mut()
|
||||
&& first.start != 0
|
||||
&& first.layout.is_some()
|
||||
{
|
||||
// The first layout lost its pages: the next one begins the document.
|
||||
first.start = 0;
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -194,6 +194,14 @@ pub struct Condition {
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
/// One layout of the document: absolute pages `start..end`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LayoutRange {
|
||||
pub name: String,
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// A stroke type with named styles resolved (unknown names draw solid).
|
||||
pub fn stroke_kind(&self, t: &StrokeType) -> StrokeType {
|
||||
@@ -900,6 +908,32 @@ impl Document {
|
||||
self.spreads.iter().enumerate().flat_map(|(si, s)| s.pages.iter().map(move |p| (si, p))).enumerate().map(|(i, (si, p))| (i, si, p))
|
||||
}
|
||||
|
||||
/// The document's layouts (Layout › Create Alternate Layout), in page order: the pages up to
|
||||
/// the first section that starts a layout are the first layout. Always at least one; a layout
|
||||
/// whose pages are gone is skipped.
|
||||
pub fn layouts(&self) -> Vec<LayoutRange> {
|
||||
let n = self.page_count();
|
||||
let mut starts: Vec<(usize, Option<&str>)> =
|
||||
self.sections.iter().filter_map(|s| s.layout.as_deref().map(|l| (s.start, Some(l)))).filter(|(st, _)| *st < n).collect();
|
||||
starts.sort_by_key(|(st, _)| *st);
|
||||
starts.dedup_by_key(|(st, _)| *st);
|
||||
if starts.first().is_none_or(|(st, _)| *st != 0) {
|
||||
starts.insert(0, (0, None));
|
||||
}
|
||||
let mut out: Vec<LayoutRange> = Vec::with_capacity(starts.len());
|
||||
for (k, (start, name)) in starts.iter().enumerate() {
|
||||
let end = starts.get(k + 1).map_or(n, |(e, _)| *e);
|
||||
let name = name.map_or_else(|| format!("Layout {}", k + 1), str::to_string);
|
||||
out.push(LayoutRange { name, start: *start, end });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The layout holding absolute page `abs` (an index into [`Document::layouts`]).
|
||||
pub fn layout_of(&self, abs: usize) -> usize {
|
||||
self.layouts().iter().rposition(|l| l.start <= abs).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The section containing absolute page `abs`.
|
||||
pub fn section_of(&self, abs: usize) -> Option<&Section> {
|
||||
self.sections.iter().filter(|s| s.start <= abs).max_by_key(|s| s.start)
|
||||
|
||||
@@ -512,6 +512,10 @@ pub struct Section {
|
||||
pub marker: String,
|
||||
#[serde(default)]
|
||||
pub include_prefix: bool,
|
||||
/// The name of the alternate layout this section starts (Pages panel: a column of its own).
|
||||
/// A layout runs from its section to the next section that starts a layout.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub layout: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -260,6 +260,7 @@ fn page_numbers_saturate_at_the_largest_start() {
|
||||
prefix: String::new(),
|
||||
marker: String::new(),
|
||||
include_prefix: false,
|
||||
layout: None,
|
||||
}];
|
||||
assert_eq!((0..3).map(|i| d.page_number(i)).collect::<Vec<_>>(), [u32::MAX; 3]);
|
||||
d.sections.push(crate::Section { start: 2, start_number: None, ..d.sections[0].clone() });
|
||||
@@ -276,6 +277,7 @@ fn page_names_follow_sections() {
|
||||
style: NumberStyle::LowerRoman,
|
||||
prefix: "A-".into(),
|
||||
marker: String::new(),
|
||||
layout: None,
|
||||
include_prefix: true,
|
||||
});
|
||||
assert_eq!(d.page_name(1), "2");
|
||||
@@ -287,6 +289,7 @@ fn page_names_follow_sections() {
|
||||
start_number: None,
|
||||
style: NumberStyle::Arabic,
|
||||
prefix: String::new(),
|
||||
layout: None,
|
||||
marker: String::new(),
|
||||
include_prefix: false,
|
||||
});
|
||||
@@ -302,6 +305,7 @@ fn consecutive_right_pages_get_separate_spreads() {
|
||||
start: 1,
|
||||
start_number: Some(1),
|
||||
style: NumberStyle::Arabic,
|
||||
layout: None,
|
||||
prefix: String::new(),
|
||||
marker: String::new(),
|
||||
include_prefix: false,
|
||||
|
||||
@@ -16,6 +16,7 @@ mod editorial;
|
||||
mod endnotes;
|
||||
mod export;
|
||||
pub(crate) use export::print_pdf;
|
||||
pub mod export_batch;
|
||||
mod file;
|
||||
mod file_info;
|
||||
pub(crate) mod find;
|
||||
@@ -175,6 +176,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(file_info::specs());
|
||||
v.extend(interchange::specs());
|
||||
v.extend(export::specs());
|
||||
v.extend(export_batch::specs());
|
||||
v.extend(edit::specs());
|
||||
v.extend(paste_svg::specs());
|
||||
v.extend(endnotes::specs());
|
||||
|
||||
@@ -246,6 +246,7 @@ fn start_at(d: &mut designcraft_doc::Document, n: u32) {
|
||||
prefix: String::new(),
|
||||
marker: String::new(),
|
||||
include_prefix: false,
|
||||
layout: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
//! File › Export Layouts and Versions: the document's alternate layouts, one file per layout or
|
||||
//! all in one, in several versions at once (a press PDF with marks and bleed, a screen PDF, PNG or
|
||||
//! JPEG images…), each version in a folder of its own, named from a template. A job can be saved
|
||||
//! as a preset and run again.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, has_doc, ok, str_param};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const ID: &str = "file.exportBatch";
|
||||
/// The most files one export writes.
|
||||
const MAX_FILES: usize = 5_000;
|
||||
/// The most versions in a job.
|
||||
const MAX_VERSIONS: usize = 24;
|
||||
/// The most pixels in one exported image (the limit EPUB page images have: an RGBA buffer of
|
||||
/// about 320 MB before encoding).
|
||||
const MAX_PIXELS: f64 = 8.0e7;
|
||||
/// The most characters in a file or folder name.
|
||||
const MAX_NAME: usize = 120;
|
||||
/// The most bytes an export returns in its reply (no `dir`): past it, export to a folder.
|
||||
const MAX_INLINE_BYTES: usize = 512 << 20;
|
||||
/// The most saved export presets, and the longest preset name.
|
||||
const MAX_PRESETS: usize = 100;
|
||||
const MAX_PRESET_NAME: usize = 80;
|
||||
|
||||
/// What a version is exported as.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ExportFormat {
|
||||
#[default]
|
||||
Pdf,
|
||||
Png,
|
||||
Jpeg,
|
||||
}
|
||||
|
||||
impl ExportFormat {
|
||||
pub fn ext(self) -> &'static str {
|
||||
match self {
|
||||
ExportFormat::Pdf => "pdf",
|
||||
ExportFormat::Png => "png",
|
||||
ExportFormat::Jpeg => "jpg",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Printer's marks of a PDF version.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct VersionMarks {
|
||||
pub crop: bool,
|
||||
pub bleed: bool,
|
||||
pub page_info: bool,
|
||||
}
|
||||
|
||||
/// One output of a job: a format and its settings. Its name is its folder.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ExportVersion {
|
||||
pub name: String,
|
||||
pub format: ExportFormat,
|
||||
/// PDF: the document's bleed.
|
||||
pub bleed: bool,
|
||||
/// PDF: printer's marks.
|
||||
pub marks: VersionMarks,
|
||||
/// PDF: none | x4 | a2b.
|
||||
pub standard: String,
|
||||
/// PDF: spreads rather than pages.
|
||||
pub spreads: bool,
|
||||
/// PDF: downsample and compress images.
|
||||
pub compress_images: bool,
|
||||
/// PNG / JPEG: pixels per inch.
|
||||
pub ppi: f64,
|
||||
/// JPEG: quality, 1–100.
|
||||
pub quality: u8,
|
||||
}
|
||||
|
||||
impl Default for ExportVersion {
|
||||
fn default() -> Self {
|
||||
ExportVersion {
|
||||
name: "PDF".into(),
|
||||
format: ExportFormat::Pdf,
|
||||
bleed: false,
|
||||
marks: VersionMarks::default(),
|
||||
standard: "none".into(),
|
||||
spreads: false,
|
||||
compress_images: false,
|
||||
ppi: 150.0,
|
||||
quality: 85,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An export job (and what a preset keeps).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ExportJob {
|
||||
/// The layouts to export, by name (empty: all of them).
|
||||
pub layouts: Vec<String>,
|
||||
/// PDF versions: all the chosen layouts in one file rather than a file per layout.
|
||||
pub combine: bool,
|
||||
/// File name template: {document}, {layout}, {version}, {date}.
|
||||
pub file_name: String,
|
||||
pub versions: Vec<ExportVersion>,
|
||||
/// Each version in a folder of its own (named after it).
|
||||
pub folders: bool,
|
||||
}
|
||||
|
||||
impl Default for ExportJob {
|
||||
fn default() -> Self {
|
||||
ExportJob { layouts: Vec::new(), combine: false, file_name: "{document} - {layout}".into(), versions: vec![], folders: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved export preset.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ExportPreset {
|
||||
pub name: String,
|
||||
pub job: ExportJob,
|
||||
}
|
||||
|
||||
/// The ready-made versions: a press PDF (crop and bleed marks, bleed), a PDF with bleed and no
|
||||
/// marks, a screen PDF, and PNG and JPEG images.
|
||||
pub fn version_templates() -> Vec<ExportVersion> {
|
||||
let pdf = ExportVersion::default();
|
||||
vec![
|
||||
ExportVersion {
|
||||
name: "PDF Print (marks + bleed)".into(),
|
||||
bleed: true,
|
||||
marks: VersionMarks { crop: true, bleed: true, page_info: true },
|
||||
..pdf.clone()
|
||||
},
|
||||
ExportVersion { name: "PDF Bleed (no marks)".into(), bleed: true, ..pdf.clone() },
|
||||
ExportVersion { name: "PDF Screen".into(), compress_images: true, ..pdf.clone() },
|
||||
ExportVersion { name: "PNG".into(), format: ExportFormat::Png, ..pdf.clone() },
|
||||
ExportVersion { name: "JPEG".into(), format: ExportFormat::Jpeg, ..pdf },
|
||||
]
|
||||
}
|
||||
|
||||
/// The built-in presets, listed before the saved ones; they can't be replaced or deleted.
|
||||
pub fn builtin_presets() -> Vec<ExportPreset> {
|
||||
let t = version_templates();
|
||||
let pick = |names: &[&str]| -> Vec<ExportVersion> { t.iter().filter(|v| names.contains(&v.name.as_str())).cloned().collect() };
|
||||
vec![
|
||||
ExportPreset {
|
||||
name: "Print + Screen".into(),
|
||||
job: ExportJob { versions: pick(&["PDF Print (marks + bleed)", "PDF Screen", "PNG"]), ..ExportJob::default() },
|
||||
},
|
||||
ExportPreset {
|
||||
name: "Press PDF per Layout".into(),
|
||||
job: ExportJob { versions: pick(&["PDF Print (marks + bleed)", "PDF Bleed (no marks)"]), ..ExportJob::default() },
|
||||
},
|
||||
ExportPreset { name: "Images per Layout".into(), job: ExportJob { versions: pick(&["PNG", "JPEG"]), ..ExportJob::default() } },
|
||||
ExportPreset {
|
||||
name: "All Layouts in One PDF".into(),
|
||||
job: ExportJob {
|
||||
combine: true,
|
||||
folders: false,
|
||||
file_name: "{document}".into(),
|
||||
versions: pick(&["PDF Bleed (no marks)"]),
|
||||
..ExportJob::default()
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(noundo "file.exportBatch", "Export Layouts and Versions…", ["File"], None,
|
||||
"{job?: {layouts?: [layout names] (default all), combine?: bool (PDF: all the layouts in one file), fileName?: \"{document} - {layout}\" (also {version}, {date}), versions: [{name, format: pdf|png|jpeg, bleed?, marks?: {crop?, bleed?, pageInfo?}, standard?: none|x4|a2b, spreads?, compressImages?, ppi?: 150, quality?: 85}], folders?: bool (default true: a folder per version)}, preset?: name (instead of or under `job`), dir?: folder to write into, dryRun?: bool (list the files only), date?: \"YYYY-MM-DD\" (for {date})} → {files: [{path, version, layout, pages, bytes, base64? (no dir)}], warnings}",
|
||||
has_doc, export_batch),
|
||||
cmd!(query "file.exportPresets", "Export Presets", [], None,
|
||||
"{} → {presets: [{name, job, builtIn}], versions: [the ready-made versions]}", super::always, |s, _| {
|
||||
let mut presets: Vec<Value> = builtin_presets().into_iter().map(|p| json!({"name": p.name, "job": p.job, "builtIn": true})).collect();
|
||||
presets.extend(s.prefs.export_presets.iter().map(|p| json!({"name": p.name, "job": p.job, "builtIn": false})));
|
||||
Ok(json!({"presets": presets, "versions": version_templates()}))
|
||||
}),
|
||||
cmd!(noundo "file.saveExportPreset", "Save Export Preset", [], None,
|
||||
"{name, job} — kept with the preferences; a saved preset of that name is replaced, built-in names are refused", super::always, save_preset),
|
||||
cmd!(noundo "file.deleteExportPreset", "Delete Export Preset", [], None, "{name} — a saved export preset", super::always, |s, p| {
|
||||
let name = str_param(p, "name").unwrap_or_default().trim().to_string();
|
||||
let before = s.prefs.export_presets.len();
|
||||
s.prefs.export_presets.retain(|x| !x.name.eq_ignore_ascii_case(&name));
|
||||
if s.prefs.export_presets.len() == before {
|
||||
return Err(bad("file.deleteExportPreset", format!("no saved export preset `{name}`")));
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "file.saveExportPreset";
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(CMD, "missing `name`"))?.to_string();
|
||||
if name.chars().count() > MAX_PRESET_NAME {
|
||||
return Err(bad(CMD, format!("a preset name has at most {MAX_PRESET_NAME} characters")));
|
||||
}
|
||||
if builtin_presets().iter().any(|b| b.name.eq_ignore_ascii_case(&name)) {
|
||||
return Err(bad(CMD, format!("`{name}` is a built-in preset")));
|
||||
}
|
||||
let job = job_param(CMD, p.get("job").ok_or_else(|| bad(CMD, "missing `job`"))?)?;
|
||||
let presets = &mut s.prefs.export_presets;
|
||||
presets.retain(|x| !x.name.eq_ignore_ascii_case(&name));
|
||||
if presets.len() >= MAX_PRESETS {
|
||||
return Err(bad(CMD, format!("at most {MAX_PRESETS} saved export presets")));
|
||||
}
|
||||
presets.push(ExportPreset { name: name.clone(), job });
|
||||
Ok(json!({"name": name}))
|
||||
}
|
||||
|
||||
/// A job from JSON, checked: versions present and sane, numbers in range.
|
||||
fn job_param(cmd: &str, v: &Value) -> Result<ExportJob> {
|
||||
let mut job: ExportJob = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("bad `job`: {e}")))?;
|
||||
if job.versions.is_empty() {
|
||||
return Err(bad(cmd, "a job needs at least one version"));
|
||||
}
|
||||
if job.versions.len() > MAX_VERSIONS {
|
||||
return Err(bad(cmd, format!("at most {MAX_VERSIONS} versions")));
|
||||
}
|
||||
if job.file_name.chars().count() > 400 {
|
||||
return Err(bad(cmd, "the file name template is too long"));
|
||||
}
|
||||
for v in &mut job.versions {
|
||||
if !v.ppi.is_finite() {
|
||||
return Err(bad(cmd, format!("version `{}`: `ppi` is a number", v.name)));
|
||||
}
|
||||
v.ppi = v.ppi.clamp(36.0, 1200.0);
|
||||
v.quality = v.quality.clamp(1, 100);
|
||||
if designcraft_pdf::Standard::parse(&v.standard).is_none() {
|
||||
return Err(bad(cmd, format!("version `{}`: unknown standard `{}` (none, x4, a2b)", v.name, v.standard)));
|
||||
}
|
||||
}
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
/// The pages (absolute, 0-based) of each chosen layout, by name; `names` empty: every layout.
|
||||
pub(crate) fn layout_pages(cmd: &str, d: &designcraft_doc::Document, names: &[String]) -> Result<Vec<(String, Vec<usize>)>> {
|
||||
let layouts = d.layouts();
|
||||
if names.is_empty() {
|
||||
return Ok(layouts.into_iter().map(|l| (l.name, (l.start..l.end).collect())).collect());
|
||||
}
|
||||
for n in names {
|
||||
if !layouts.iter().any(|l| l.name == *n) {
|
||||
return Err(bad(cmd, format!("no layout `{n}`")));
|
||||
}
|
||||
}
|
||||
// In document order, whatever order they were named in.
|
||||
Ok(layouts.into_iter().filter(|l| names.contains(&l.name)).map(|l| (l.name, (l.start..l.end).collect())).collect())
|
||||
}
|
||||
|
||||
/// `s` as a file or folder name: no path separators, reserved or control characters, no leading
|
||||
/// dots, no Windows device name (`CON`, `NUL`… get a leading `_`), at most [`MAX_NAME`]
|
||||
/// characters; "Untitled" when nothing is left.
|
||||
pub fn safe_name(s: &str) -> String {
|
||||
let cleaned: String = s.chars().map(|c| if c.is_control() || r#"/\:*?"<>|"#.contains(c) { ' ' } else { c }).collect();
|
||||
let words: Vec<&str> = cleaned.split_whitespace().collect();
|
||||
let joined = words.join(" ");
|
||||
let trimmed = joined.trim_start_matches('.').trim();
|
||||
let capped: String = trimmed.chars().take(MAX_NAME).collect();
|
||||
let capped = capped.trim_end_matches(['.', ' ']).to_string();
|
||||
if capped.is_empty() {
|
||||
"Untitled".into()
|
||||
} else if crate::file_access::is_device_name(&capped) {
|
||||
format!("_{capped}")
|
||||
} else {
|
||||
capped
|
||||
}
|
||||
}
|
||||
|
||||
/// `name.ext`, or `name 2.ext`, `name 3.ext`… when `taken` has it (case-insensitively).
|
||||
fn unique(taken: &mut Vec<String>, name: &str, ext: &str) -> String {
|
||||
let mut k = 1usize;
|
||||
loop {
|
||||
let candidate = if k == 1 { format!("{name}.{ext}") } else { format!("{name} {k}.{ext}") };
|
||||
if !taken.iter().any(|t| t.eq_ignore_ascii_case(&candidate)) {
|
||||
taken.push(candidate.clone());
|
||||
return candidate;
|
||||
}
|
||||
k = k.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Today as YYYY-MM-DD (UTC), where there is a clock (chrono, through `designcraft_doc::time`).
|
||||
fn today() -> String {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let (y, date) = designcraft_doc::time::date_parts(designcraft_doc::vars::now().div_euclid(86_400));
|
||||
format!("{y:04}-{}", date.format("%m-%d"))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// One file a job writes.
|
||||
struct Planned {
|
||||
/// Relative to the export folder: `version folder/name.ext`.
|
||||
path: String,
|
||||
version: usize,
|
||||
layout: String,
|
||||
/// Absolute, 0-based.
|
||||
pages: Vec<usize>,
|
||||
}
|
||||
|
||||
/// The files `job` writes, in order: for each version, a PDF per layout (or one for all), or an
|
||||
/// image per page.
|
||||
fn plan(s: &Session, job: &ExportJob, date: &str) -> Result<Vec<Planned>> {
|
||||
let d = &s.doc()?.doc;
|
||||
let groups = layout_pages(ID, d, &job.layouts)?;
|
||||
if groups.iter().all(|(_, p)| p.is_empty()) {
|
||||
return Err(bad(ID, "the chosen layouts have no pages"));
|
||||
}
|
||||
let document = if d.title.trim().is_empty() { "Untitled" } else { d.title.as_str() };
|
||||
let mut out = Vec::new();
|
||||
let mut folders: Vec<String> = Vec::new();
|
||||
for (vi, v) in job.versions.iter().enumerate() {
|
||||
let folder = if job.folders {
|
||||
let f = safe_name(&v.name);
|
||||
// Two versions with one name share no folder.
|
||||
let f = unique(&mut folders, &f, "").trim_end_matches('.').to_string();
|
||||
format!("{f}/")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let mut taken: Vec<String> = out.iter().filter_map(|p: &Planned| p.path.strip_prefix(folder.as_str()).map(str::to_string)).collect();
|
||||
let name = |layout: &str| {
|
||||
safe_name(
|
||||
&job.file_name.replace("{document}", document).replace("{layout}", layout).replace("{version}", &v.name).replace("{date}", date),
|
||||
)
|
||||
};
|
||||
match v.format {
|
||||
ExportFormat::Pdf if job.combine => {
|
||||
let pages: Vec<usize> = groups.iter().flat_map(|(_, p)| p.iter().copied()).collect();
|
||||
let file = unique(&mut taken, &name("All Layouts"), "pdf");
|
||||
out.push(Planned { path: format!("{folder}{file}"), version: vi, layout: "All Layouts".into(), pages });
|
||||
}
|
||||
ExportFormat::Pdf => {
|
||||
for (layout, pages) in groups.iter().filter(|(_, p)| !p.is_empty()) {
|
||||
let file = unique(&mut taken, &name(layout), "pdf");
|
||||
out.push(Planned { path: format!("{folder}{file}"), version: vi, layout: layout.clone(), pages: pages.clone() });
|
||||
}
|
||||
}
|
||||
ExportFormat::Png | ExportFormat::Jpeg => {
|
||||
for (layout, pages) in &groups {
|
||||
let base = name(layout);
|
||||
for &abs in pages {
|
||||
let stem = safe_name(&format!("{base} - {}", s.page_label(abs)));
|
||||
let file = unique(&mut taken, &stem, v.format.ext());
|
||||
out.push(Planned { path: format!("{folder}{file}"), version: vi, layout: layout.clone(), pages: vec![abs] });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.len() > MAX_FILES {
|
||||
return Err(bad(ID, format!("the export would write more than {MAX_FILES} files")));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The `file.exportPdf` parameters of a PDF version for `pages` (absolute, 0-based).
|
||||
fn pdf_params(v: &ExportVersion, pages: &[usize], title: &str) -> Value {
|
||||
json!({
|
||||
"pages": pages.iter().map(|p| p.saturating_add(1)).collect::<Vec<_>>(),
|
||||
"bleed": v.bleed,
|
||||
"marks": {"crop": v.marks.crop, "bleed": v.marks.bleed, "pageInfo": v.marks.page_info},
|
||||
"standard": v.standard,
|
||||
"spreads": v.spreads,
|
||||
"compressImages": v.compress_images,
|
||||
"tagged": true,
|
||||
"title": title,
|
||||
})
|
||||
}
|
||||
|
||||
/// The bytes of one planned file.
|
||||
fn render(s: &Session, v: &ExportVersion, f: &Planned) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
let d = &s.doc()?.doc;
|
||||
match v.format {
|
||||
ExportFormat::Pdf => {
|
||||
let r = super::export::pdf_report(d, &s.cache, &pdf_params(v, &f.pages, &d.title))?;
|
||||
Ok((r.bytes, r.warnings))
|
||||
}
|
||||
ExportFormat::Png | ExportFormat::Jpeg => {
|
||||
let abs = f.pages.first().copied().ok_or_else(|| bad(ID, "no page"))?;
|
||||
let page = d.page(abs).ok_or_else(|| bad(ID, format!("no page {}", abs.saturating_add(1))))?;
|
||||
let scale = v.ppi / 72.0;
|
||||
if page.width * scale * page.height * scale > MAX_PIXELS {
|
||||
return Err(bad(ID, format!("page {} at {} ppi is too large for an image; lower the resolution", s.page_label(abs), v.ppi)));
|
||||
}
|
||||
let mut r = designcraft_render::Renderer::new();
|
||||
let opts = designcraft_render::RenderOptions { printing_only: true, rich_black: s.prefs.rich_black_output, ..Default::default() };
|
||||
let img = r.render_page(d, &s.cache, abs, scale, false, &opts).ok_or_else(|| bad(ID, "the page couldn't be rendered"))?;
|
||||
Ok((if v.format == ExportFormat::Png { img.to_png() } else { img.to_jpeg(v.quality) }, vec![]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `e` for the file at `path`, saying how many files were written to disk before it (they stay).
|
||||
fn partly(e: EngineError, path: &str, written: usize, to_disk: bool) -> EngineError {
|
||||
if to_disk && written > 0 { bad(ID, format!("{path}: {e}; the {written} file(s) before it were written")) } else { e }
|
||||
}
|
||||
|
||||
/// Write `bytes` at `rel` inside `dir`, making the version's folder.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_into(dir: &str, rel: &str, bytes: &[u8]) -> Result<String> {
|
||||
let path = std::path::Path::new(dir).join(rel);
|
||||
if let Some(parent) = path.parent() {
|
||||
crate::io::create_dir_all(parent).map_err(EngineError::Other)?;
|
||||
}
|
||||
let s = path.to_string_lossy().into_owned();
|
||||
crate::io::write_file(&s, bytes).map_err(EngineError::Other)?;
|
||||
Ok(s)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn write_into(_dir: &str, _rel: &str, _bytes: &[u8]) -> Result<String> {
|
||||
Err(EngineError::Other("no file system on the web; omit `dir` to get the files".into()))
|
||||
}
|
||||
|
||||
fn export_batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let job = match (p.get("job"), str_param(p, "preset")) {
|
||||
(Some(j), _) => job_param(ID, j)?,
|
||||
(None, Some(name)) => {
|
||||
let preset = builtin_presets()
|
||||
.into_iter()
|
||||
.chain(s.prefs.export_presets.iter().cloned())
|
||||
.find(|x| x.name.eq_ignore_ascii_case(name.trim()))
|
||||
.ok_or_else(|| bad(ID, format!("no export preset `{name}`")))?;
|
||||
job_param(ID, &serde_json::to_value(&preset.job).map_err(|e| bad(ID, e.to_string()))?)?
|
||||
}
|
||||
(None, None) => return Err(bad(ID, "give a `job` or a `preset`")),
|
||||
};
|
||||
let date = match str_param(p, "date") {
|
||||
Some(d) => safe_name(d),
|
||||
None => today(),
|
||||
};
|
||||
let dir = str_param(p, "dir").map(str::to_string);
|
||||
let planned = plan(s, &job, &date)?;
|
||||
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
|
||||
let mut files = Vec::with_capacity(planned.len());
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
let mut inline = 0usize;
|
||||
for f in &planned {
|
||||
let v = job.versions.get(f.version).ok_or_else(|| bad(ID, "no such version"))?;
|
||||
let mut entry = json!({
|
||||
"path": f.path,
|
||||
"version": v.name,
|
||||
"layout": f.layout,
|
||||
"pages": f.pages.iter().map(|p| p.saturating_add(1)).collect::<Vec<_>>(),
|
||||
});
|
||||
if !dry {
|
||||
let (bytes, w) = render(s, v, f).map_err(|e| partly(e, &f.path, files.len(), dir.is_some()))?;
|
||||
for w in w {
|
||||
let w = format!("{}: {w}", f.path);
|
||||
if !warnings.contains(&w) {
|
||||
warnings.push(w);
|
||||
}
|
||||
}
|
||||
entry["bytes"] = json!(bytes.len());
|
||||
match &dir {
|
||||
Some(dir) => entry["path"] = json!(write_into(dir, &f.path, &bytes).map_err(|e| partly(e, &f.path, files.len(), true))?),
|
||||
None => {
|
||||
inline = inline.saturating_add(bytes.len());
|
||||
if inline > MAX_INLINE_BYTES {
|
||||
return Err(bad(ID, format!("the files come to more than {} MB: give a `dir` to export to", MAX_INLINE_BYTES >> 20)));
|
||||
}
|
||||
entry["base64"] = json!(super::base64_encode(&bytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
files.push(entry);
|
||||
}
|
||||
Ok(json!({"files": files, "warnings": warnings}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn two_layouts() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 2, "facingPages": false, "width": 300, "height": 400, "bleed": 9, "title": "Mag"})).unwrap();
|
||||
s.execute("frame.create", &json!({"rect": [20, 20, 200, 200]})).unwrap();
|
||||
s.execute("layout.createAlternate", &json!({"name": "Tablet H", "width": 400, "height": 300, "fromName": "Print"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn job(versions: &[&str]) -> Value {
|
||||
let t = version_templates();
|
||||
let v: Vec<_> = versions.iter().map(|n| t.iter().find(|x| x.name == *n).unwrap().clone()).collect();
|
||||
json!({"versions": v})
|
||||
}
|
||||
|
||||
fn paths(r: &Value) -> Vec<String> {
|
||||
r["files"].as_array().unwrap().iter().map(|f| f["path"].as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_per_layout_and_version_in_folders() {
|
||||
let mut s = two_layouts();
|
||||
let r = s.execute(ID, &json!({"job": job(&["PDF Print (marks + bleed)", "PNG"]), "dryRun": true})).unwrap();
|
||||
assert_eq!(
|
||||
paths(&r),
|
||||
[
|
||||
"PDF Print (marks + bleed)/Mag - Print.pdf",
|
||||
"PDF Print (marks + bleed)/Mag - Tablet H.pdf",
|
||||
"PNG/Mag - Print - 1.png",
|
||||
"PNG/Mag - Print - 2.png",
|
||||
"PNG/Mag - Tablet H - 1.png",
|
||||
"PNG/Mag - Tablet H - 2.png",
|
||||
]
|
||||
);
|
||||
assert_eq!(r["files"][1]["pages"], json!([3, 4]));
|
||||
// One layout on its own, all layouts in one PDF, no folders, another name.
|
||||
let mut one = job(&["PDF Screen"]);
|
||||
one["layouts"] = json!(["Tablet H"]);
|
||||
assert_eq!(paths(&s.execute(ID, &json!({"job": one, "dryRun": true})).unwrap()), ["PDF Screen/Mag - Tablet H.pdf"]);
|
||||
let mut all = job(&["PDF Screen"]);
|
||||
all["combine"] = json!(true);
|
||||
all["folders"] = json!(false);
|
||||
all["fileName"] = json!("{document} {version} {date}");
|
||||
let r = s.execute(ID, &json!({"job": all, "dryRun": true, "date": "2026-10-20"})).unwrap();
|
||||
assert_eq!(paths(&r), ["Mag PDF Screen 2026-10-20.pdf"]);
|
||||
assert_eq!(r["files"][0]["pages"], json!([1, 2, 3, 4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_export_renders_each_file() {
|
||||
let mut s = two_layouts();
|
||||
let r = s.execute(ID, &json!({"job": job(&["PDF Print (marks + bleed)", "PDF Bleed (no marks)", "JPEG"])})).unwrap();
|
||||
let files = r["files"].as_array().unwrap();
|
||||
assert_eq!(files.len(), 2 + 2 + 4);
|
||||
let bytes = |i: usize| super::super::file::base64_decode(files[i]["base64"].as_str().unwrap());
|
||||
for (i, f) in files.iter().enumerate().take(4) {
|
||||
assert!(bytes(i).starts_with(b"%PDF-"), "{}", f["path"]);
|
||||
}
|
||||
assert!(bytes(4).starts_with(&[0xFF, 0xD8]), "a JPEG");
|
||||
// Marks need room around the bleed: the marked PDF's page is larger.
|
||||
let size = |b: &[u8]| designcraft_render::pdf_page_size(b, 0).unwrap();
|
||||
let (marked, bled) = (size(&bytes(0)), size(&bytes(2)));
|
||||
assert!(marked.0 > bled.0 && marked.1 > bled.1, "{marked:?} {bled:?}");
|
||||
assert_eq!(designcraft_render::pdf_page_count(&bytes(1)), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_go_into_a_folder_per_version() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut s = two_layouts();
|
||||
let r = s.execute(ID, &json!({"preset": "Print + Screen", "dir": dir.path().to_str().unwrap()})).unwrap();
|
||||
assert_eq!(r["files"].as_array().unwrap().len(), 2 + 2 + 4);
|
||||
for f in ["PDF Print (marks + bleed)/Mag - Print.pdf", "PDF Screen/Mag - Tablet H.pdf", "PNG/Mag - Tablet H - 2.png"] {
|
||||
assert!(dir.path().join(f).is_file(), "{f}");
|
||||
}
|
||||
let pdf = std::fs::read(dir.path().join("PDF Screen/Mag - Print.pdf")).unwrap();
|
||||
assert!(pdf.starts_with(b"%PDF-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_are_made_safe_and_unique() {
|
||||
assert_eq!(safe_name("../a/b:c*?"), "a b c");
|
||||
assert_eq!(safe_name(" ... "), "Untitled");
|
||||
assert_eq!(safe_name(&"x".repeat(500)).chars().count(), MAX_NAME);
|
||||
// Windows device names name no file.
|
||||
for (name, safe) in [("CON", "_CON"), ("nul", "_nul"), ("com1.x", "_com1.x"), ("Console", "Console")] {
|
||||
assert_eq!(safe_name(name), safe);
|
||||
}
|
||||
let mut s = two_layouts();
|
||||
let mut j = job(&["PNG", "PNG"]);
|
||||
j["fileName"] = json!("../{document}");
|
||||
let r = s.execute(ID, &json!({"job": j, "dryRun": true})).unwrap();
|
||||
let p = paths(&r);
|
||||
assert!(p.iter().all(|x| !x.contains("..")), "{p:?}");
|
||||
assert!(p.contains(&"PNG/Mag - 1.png".to_string()) && p.contains(&"PNG/Mag - 1 2.png".to_string()), "{p:?}");
|
||||
assert!(p.iter().any(|x| x.starts_with("PNG 2/")), "two versions, two folders: {p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_jobs_are_refused() {
|
||||
let mut s = two_layouts();
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"job": {"versions": []}}),
|
||||
json!({"job": {"versions": [{"format": "tiff"}]}}),
|
||||
json!({"job": {"versions": [{"standard": "x9"}]}}),
|
||||
json!({"job": {"versions": [{"format": "png"}], "layouts": ["Nope"]}}),
|
||||
json!({"job": "pdf"}),
|
||||
json!({"preset": "Nope"}),
|
||||
] {
|
||||
assert!(s.execute(ID, &p).is_err(), "{p}");
|
||||
}
|
||||
// A huge resolution is capped, not a crash; past 80 million pixels an image is refused.
|
||||
let mut tall = Session::new();
|
||||
tall.execute("file.new", &json!({"pages": 1, "width": 3000, "height": 3000})).unwrap();
|
||||
assert!(tall.execute(ID, &json!({"job": {"versions": [{"format": "png", "ppi": 300}]}})).is_err(), "12500² pixels");
|
||||
let r = s.execute(ID, &json!({"job": {"versions": [{"format": "png", "ppi": 1e12}], "layouts": ["Print"]}, "dryRun": true})).unwrap();
|
||||
assert_eq!(r["files"].as_array().unwrap().len(), 2);
|
||||
let mut big = Session::new();
|
||||
big.execute("file.new", &json!({"pages": 1, "width": 10000, "height": 10000})).unwrap();
|
||||
assert!(big.execute(ID, &json!({"job": {"versions": [{"format": "png", "ppi": 1200}]}})).is_err(), "too many pixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_are_saved_listed_and_deleted() {
|
||||
let mut s = two_layouts();
|
||||
let builtin = s.execute("file.exportPresets", &json!({})).unwrap()["presets"].as_array().unwrap().len();
|
||||
s.execute("file.saveExportPreset", &json!({"name": "Client", "job": job(&["PNG"])})).unwrap();
|
||||
s.execute("file.saveExportPreset", &json!({"name": "client", "job": job(&["JPEG"])})).unwrap();
|
||||
let list = s.execute("file.exportPresets", &json!({})).unwrap();
|
||||
assert_eq!(list["presets"].as_array().unwrap().len(), builtin + 1, "same name replaces");
|
||||
let r = s.execute(ID, &json!({"preset": "CLIENT", "dryRun": true})).unwrap();
|
||||
assert!(paths(&r)[0].ends_with(".jpg"));
|
||||
assert!(s.execute("file.saveExportPreset", &json!({"name": "Print + Screen", "job": job(&["PNG"])})).is_err());
|
||||
assert!(s.execute("file.saveExportPreset", &json!({"name": "X", "job": {"versions": []}})).is_err());
|
||||
assert!(s.execute("file.deleteExportPreset", &json!({"name": "Print + Screen"})).is_err());
|
||||
s.execute("file.deleteExportPreset", &json!({"name": "Client"})).unwrap();
|
||||
assert!(s.execute(ID, &json!({"preset": "Client", "dryRun": true})).is_err());
|
||||
}
|
||||
}
|
||||
@@ -40,27 +40,76 @@ pub(super) fn parent_ref(cmd: &str, d: &designcraft_doc::Document, v: Option<&Va
|
||||
}
|
||||
}
|
||||
|
||||
/// Layout › Pages › Parent Options: Based on Parent. A base that would make a cycle is refused.
|
||||
/// Whether a parent other than index `except` is labelled `label` (`A-Parent`). Parents may share
|
||||
/// a prefix, but a label names one parent.
|
||||
pub(super) fn parent_label_taken(d: &designcraft_doc::Document, label: &str, except: Option<usize>) -> bool {
|
||||
d.parents.iter().enumerate().any(|(i, x)| Some(i) != except && x.parent.as_ref().is_some_and(|o| o.label() == label))
|
||||
}
|
||||
|
||||
/// A parent prefix parameter: trimmed, 1–4 characters, no `-` (it separates prefix and name).
|
||||
pub(super) fn parent_prefix_param(cmd: &str, p: &Value) -> Result<Option<String>> {
|
||||
let Some(v) = p.get("prefix") else { return Ok(None) };
|
||||
let s = v.as_str().ok_or_else(|| bad(cmd, "`prefix` is text"))?.trim();
|
||||
if s.is_empty() || s.chars().count() > 4 || s.contains('-') {
|
||||
return Err(bad(cmd, "a parent prefix has 1 to 4 characters and no `-`"));
|
||||
}
|
||||
Ok(Some(s.to_string()))
|
||||
}
|
||||
|
||||
/// A parent name parameter: trimmed, not empty, at most 100 characters.
|
||||
pub(super) fn parent_name_param(cmd: &str, p: &Value) -> Result<Option<String>> {
|
||||
let Some(v) = p.get("name") else { return Ok(None) };
|
||||
let s = v.as_str().ok_or_else(|| bad(cmd, "`name` is text"))?.trim();
|
||||
if s.is_empty() || s.chars().count() > 100 {
|
||||
return Err(bad(cmd, "a parent name has 1 to 100 characters"));
|
||||
}
|
||||
Ok(Some(s.to_string()))
|
||||
}
|
||||
|
||||
/// Layout › Pages › Parent Options: prefix, name and Based on Parent. A base that would make a
|
||||
/// cycle is refused.
|
||||
pub(super) fn parent_options(s: &mut crate::Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "layout.parents.options";
|
||||
let prefix = parent_prefix_param(CMD, p)?;
|
||||
let name = parent_name_param(CMD, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let Some(Some(id)) = parent_ref(CMD, d, p.get("parent"))? else { return Err(bad(CMD, "`parent` names the parent to change")) };
|
||||
let pi = d.parent_index(id).ok_or_else(|| bad(CMD, format!("no parent with id {}", id.0)))?;
|
||||
let base = parent_ref(CMD, d, p.get("basedOn"))?.ok_or_else(|| bad(CMD, "missing `basedOn` (a parent, or null for [None])"))?;
|
||||
let info = d.parents.get(pi).and_then(|x| x.parent.clone()).ok_or_else(|| bad(CMD, "no such parent"))?;
|
||||
let base = match parent_ref(CMD, d, p.get("basedOn"))? {
|
||||
Some(b) => b,
|
||||
None => info.based_on,
|
||||
};
|
||||
if base == Some(id) {
|
||||
return Err(bad(CMD, format!("{} cannot be based on itself", info.label())));
|
||||
}
|
||||
let label = |d: &designcraft_doc::Document, i: usize| d.parents.get(i).and_then(|x| x.parent.as_ref()).map(|i| i.label());
|
||||
if let Some(b) = base
|
||||
&& d.parent_base_cycles(pi, b)
|
||||
{
|
||||
let (name, b) = (label(d, pi).unwrap_or_default(), d.parent_index(b).and_then(|i| label(d, i)).unwrap_or_default());
|
||||
let (name, b) = (info.label(), d.parent_index(b).and_then(|i| label(d, i)).unwrap_or_default());
|
||||
return Err(bad(CMD, format!("{name} cannot be based on {b}: that would make a cycle")));
|
||||
}
|
||||
let out = json!({"parent": label(d, pi), "basedOn": base.and_then(|b| d.parent_index(b)).and_then(|i| label(d, i))});
|
||||
if d.parents.get(pi).and_then(|x| x.parent.as_ref()).and_then(|i| i.based_on) == base {
|
||||
let new = designcraft_doc::ParentInfo {
|
||||
prefix: prefix.unwrap_or_else(|| info.prefix.clone()),
|
||||
name: name.unwrap_or_else(|| info.name.clone()),
|
||||
based_on: base,
|
||||
};
|
||||
let out = json!({
|
||||
"parent": new.label(),
|
||||
"prefix": new.prefix,
|
||||
"name": new.name,
|
||||
"basedOn": base.and_then(|b| d.parent_index(b)).and_then(|i| label(d, i)),
|
||||
});
|
||||
if new == info {
|
||||
return Ok(out);
|
||||
}
|
||||
if parent_label_taken(d, &new.label(), Some(pi)) {
|
||||
return Err(bad(CMD, format!("a parent named {} exists", new.label())));
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
let info = d.parents.get_mut(pi).map(std::sync::Arc::make_mut).and_then(|x| x.parent.as_mut()).ok_or_else(|| bad(CMD, "no such parent"))?;
|
||||
info.based_on = base;
|
||||
*info = new;
|
||||
Ok(out)
|
||||
})
|
||||
}
|
||||
@@ -269,6 +318,81 @@ mod parent_tests {
|
||||
assert_eq!(parent_of(&s, 1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_parents_take_the_next_letters_and_options_rename_them() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({"pages": 2})).unwrap();
|
||||
let b = s.execute("layout.parents.new", &json!({})).unwrap();
|
||||
assert_eq!(b["prefix"], "B");
|
||||
let c = s.execute("layout.parents.new", &json!({"basedOn": "A"})).unwrap();
|
||||
assert_eq!(c["prefix"], "C");
|
||||
let (b, c) = (b["id"].as_u64().unwrap(), c["id"].as_u64().unwrap());
|
||||
let info = |s: &crate::Session, id: u64| {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
d.parents.iter().find(|p| p.id.0 == id).and_then(|p| p.parent.clone()).unwrap()
|
||||
};
|
||||
assert!(info(&s, c).based_on.is_some());
|
||||
s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": b})).unwrap();
|
||||
let r = s.execute("layout.parents.options", &json!({"parent": "B", "prefix": " Z ", "name": "Cover"})).unwrap();
|
||||
assert_eq!(r["parent"], "Z-Cover");
|
||||
assert_eq!(info(&s, b).label(), "Z-Cover");
|
||||
// The page keeps its parent, now called Z-Cover; Based On is untouched when not given.
|
||||
assert_eq!(parent_of(&s, 1), Some(b));
|
||||
assert!(info(&s, c).based_on.is_some());
|
||||
s.execute("layout.parents.options", &json!({"parent": "C", "name": "Back"})).unwrap();
|
||||
assert_eq!(info(&s, c).label(), "C-Back");
|
||||
assert!(info(&s, c).based_on.is_some(), "a rename keeps the base");
|
||||
// Bad prefixes and names change nothing and add no history.
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
for bad in [json!({"prefix": ""}), json!({"prefix": "ABCDE"}), json!({"prefix": "A-B"}), json!({"name": " "}), json!({"prefix": 3})] {
|
||||
let mut p = bad.clone();
|
||||
p["parent"] = json!(b);
|
||||
assert!(s.execute("layout.parents.options", &p).is_err(), "{bad}");
|
||||
assert!(s.execute("layout.parents.new", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.execute("layout.parents.options", &json!({"parent": "C", "basedOn": "C-Back"})).is_err());
|
||||
// A label names one parent: taking another parent's label is refused, a prefix may be shared.
|
||||
assert!(s.execute("layout.parents.options", &json!({"parent": "C-Back", "prefix": "Z", "name": "Cover"})).is_err());
|
||||
assert!(s.execute("layout.parents.new", &json!({"prefix": "Z", "name": "Cover"})).is_err());
|
||||
assert!(s.execute("layout.parents.options", &json!({"parent": "C-Back", "prefix": "Z"})).is_ok());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
assert_eq!(info(&s, b).label(), "Z-Cover");
|
||||
// Four characters, including non-Latin ones, are fine.
|
||||
s.execute("layout.parents.options", &json!({"parent": b, "prefix": "表紙ページ".chars().take(4).collect::<String>()})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(info(&s, b).label(), "Z-Cover");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_parent_copies_pages_and_items_with_a_new_prefix() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({"pages": 2})).unwrap();
|
||||
let a = json!({"kind": "parent", "index": 0});
|
||||
s.execute("frame.create", &json!({"spread": a, "rect": [40, 40, 200, 100], "content": "text"})).unwrap();
|
||||
s.execute("frame.create", &json!({"spread": a, "rect": [60, 200, 160, 300]})).unwrap();
|
||||
let n = s.doc().unwrap().doc.parents.len();
|
||||
let r = s.execute("layout.parents.duplicate", &json!({"parent": "A-Parent"})).unwrap();
|
||||
assert_eq!(r["prefix"], "B");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.parents.len(), n + 1);
|
||||
let (src, copy) = (&d.parents[0], &d.parents[n]);
|
||||
assert_eq!(copy.id.0, r["id"].as_u64().unwrap());
|
||||
assert_eq!(copy.parent.as_ref().unwrap().label(), "B-Parent");
|
||||
assert_eq!(copy.pages.len(), src.pages.len());
|
||||
assert_eq!(copy.items.len(), 2);
|
||||
assert!(copy.pages.iter().all(|p| src.pages.iter().all(|q| q.id != p.id)));
|
||||
assert!(copy.items.iter().all(|i| src.items.iter().all(|j| j.id != i.id)));
|
||||
// The copied text frame has its own story.
|
||||
let story = |it: &designcraft_doc::Item| it.text_frame().map(|t| t.story);
|
||||
let (s0, s1) = (src.items.iter().find_map(|i| story(i)), copy.items.iter().find_map(|i| story(i)));
|
||||
assert!(s0.is_some() && s1.is_some() && s0 != s1);
|
||||
assert!(s.execute("layout.parents.duplicate", &json!({"parent": "Nope"})).is_err());
|
||||
assert!(s.execute("layout.parents.duplicate", &json!({})).is_err());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.parents.len(), n);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_parents_shows_the_named_parent() {
|
||||
let mut s = crate::Session::new();
|
||||
@@ -471,7 +595,7 @@ mod parent_base_tests {
|
||||
assert!(paper(page(&s, 1).pixel(50, 100)), "B alone does not show A's items");
|
||||
|
||||
let r = s.execute("layout.parents.options", &json!({"parent": "B", "basedOn": "A"})).unwrap();
|
||||
assert_eq!(r, json!({"parent": "B-Opening", "basedOn": "A-Parent"}));
|
||||
assert_eq!(r, json!({"parent": "B-Opening", "prefix": "B", "name": "Opening", "basedOn": "A-Parent"}));
|
||||
let img = page(&s, 1);
|
||||
assert!(black(img.pixel(50, 100)), "A's item: {:?}", img.pixel(50, 100));
|
||||
assert!(cyan(img.pixel(150, 150)), "B's item over A's: {:?}", img.pixel(150, 150));
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use super::layers::set_layer;
|
||||
use super::margins::{adjust_layout, column_guides_unlocked, margin_boxes, move_column_guide, set_margins_columns, set_page_width};
|
||||
use super::pages::{document_setup, page_options, page_size};
|
||||
use super::parents::{parent_options, parent_page_size, parent_page_target, parent_ref, parent_spread_index};
|
||||
use super::parents::{
|
||||
parent_label_taken, parent_name_param, parent_options, parent_page_size, parent_page_target, parent_prefix_param, parent_ref, parent_spread_index,
|
||||
};
|
||||
use super::sections::{ChapterEdit, SectionEdit, chapter_numbering, start_number};
|
||||
use super::{CommandSpec, LAYER_COLORS, Layer, LayerId, Margins, SpreadId, Value, bad, cmd, has_doc, json, ok, str_param};
|
||||
|
||||
@@ -178,27 +180,69 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
}
|
||||
),
|
||||
cmd!("layout.parents.new", "New Parent…", ["Layout", "Pages"], None, "{prefix?, name?: \"Parent\"}", has_doc, |s, p| {
|
||||
let name = str_param(p, "name").unwrap_or("Parent").to_string();
|
||||
s.edit(|d, _| {
|
||||
let prefix = str_param(p, "prefix").map(str::to_string).unwrap_or_else(|| d.next_parent_prefix());
|
||||
let src = d.page(0).cloned();
|
||||
let (columns, margins) =
|
||||
src.as_ref().map_or_else(|| (designcraft_doc::Columns::default(), Margins::uniform(36.0)), |p| (p.columns.clone(), p.margins));
|
||||
let id = d.add_parent(&prefix, &name, columns.count, columns.gutter, margins);
|
||||
// Dragged column guides carry over to the parent pages they still fit, mirrored
|
||||
// on the parent page across the spine from the first page.
|
||||
if let (Some(src), Some(sp)) = (&src, d.parents.iter_mut().find(|x| x.id == id)) {
|
||||
for pg in &mut std::sync::Arc::make_mut(sp).pages {
|
||||
let columns = pg.columns_from(src);
|
||||
if columns.valid_positions(pg.margin_rect().width()).is_some() {
|
||||
pg.columns.positions = columns.positions;
|
||||
cmd!(
|
||||
"layout.parents.new",
|
||||
"New Parent…",
|
||||
["Layout", "Pages"],
|
||||
None,
|
||||
"{prefix?: 1–4 characters without `-` (default: the next free letter), name?: \"Parent\", basedOn?: id|\"A-Parent\"|\"A\"|null} → {id, prefix}",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
const ID: &str = "layout.parents.new";
|
||||
let name = parent_name_param(ID, p)?.unwrap_or_else(|| "Parent".into());
|
||||
let prefix = parent_prefix_param(ID, p)?;
|
||||
let base = parent_ref(ID, &s.doc()?.doc, p.get("basedOn"))?.flatten();
|
||||
s.edit(|d, _| {
|
||||
let prefix = prefix.unwrap_or_else(|| d.next_parent_prefix());
|
||||
let label = designcraft_doc::ParentInfo { prefix: prefix.clone(), name: name.clone(), based_on: None }.label();
|
||||
if parent_label_taken(d, &label, None) {
|
||||
return Err(bad(ID, format!("a parent named {label} exists")));
|
||||
}
|
||||
let src = d.page(0).cloned();
|
||||
let (columns, margins) = src
|
||||
.as_ref()
|
||||
.map_or_else(|| (designcraft_doc::Columns::default(), Margins::uniform(36.0)), |p| (p.columns.clone(), p.margins));
|
||||
let id = d.add_parent(&prefix, &name, columns.count, columns.gutter, margins);
|
||||
// Dragged column guides carry over to the parent pages they still fit, mirrored
|
||||
// on the parent page across the spine from the first page.
|
||||
if let (Some(src), Some(sp)) = (&src, d.parents.iter_mut().find(|x| x.id == id)) {
|
||||
for pg in &mut std::sync::Arc::make_mut(sp).pages {
|
||||
let columns = pg.columns_from(src);
|
||||
if columns.valid_positions(pg.margin_rect().width()).is_some() {
|
||||
pg.columns.positions = columns.positions;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"id": id.0, "prefix": prefix}))
|
||||
})
|
||||
}),
|
||||
if let Some(info) = d.parents.iter_mut().find(|x| x.id == id).map(std::sync::Arc::make_mut).and_then(|x| x.parent.as_mut()) {
|
||||
info.based_on = base;
|
||||
}
|
||||
Ok(json!({"id": id.0, "prefix": prefix}))
|
||||
})
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"layout.parents.duplicate",
|
||||
"Duplicate Parent Spread",
|
||||
["Layout", "Pages"],
|
||||
None,
|
||||
"{parent: id|\"A-Parent\"|\"A\"} — a copy with the next free prefix, its pages and items → {id, prefix}",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
const ID: &str = "layout.parents.duplicate";
|
||||
let Some(Some(id)) = parent_ref(ID, &s.doc()?.doc, p.get("parent"))? else {
|
||||
return Err(bad(ID, "`parent` names the parent to duplicate"));
|
||||
};
|
||||
s.edit(|d, _| {
|
||||
let prefix = d.next_parent_prefix();
|
||||
let src = d.clone();
|
||||
let ids: Vec<_> =
|
||||
src.parent_index(id).and_then(|i| src.parents.get(i)).map(|sp| sp.items.iter().map(|i| i.id).collect()).unwrap_or_default();
|
||||
let (new, at) = d.duplicate_parent_shell(id, &prefix)?;
|
||||
crate::cmd::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Parent(at), designcraft_geom::Vec2::ZERO)?;
|
||||
Ok(json!({"id": new.0, "prefix": prefix}))
|
||||
})
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"layout.parents.delete",
|
||||
"Delete Parent",
|
||||
@@ -261,7 +305,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Parent Options…",
|
||||
[],
|
||||
None,
|
||||
"{parent: id|\"B-Parent\"|\"B\", basedOn: id|\"A-Parent\"|\"A\"|null (= [None])} — the parent shows its base's items under its own, and so do the pages that use it → {parent, basedOn}",
|
||||
"{parent: id|\"B-Parent\"|\"B\", prefix?: 1–4 characters without `-`, name?, basedOn?: id|\"A-Parent\"|\"A\"|null (= [None])} — rename the parent; with a base it shows the base's items under its own, and so do the pages that use it → {parent, prefix, name, basedOn}",
|
||||
has_doc,
|
||||
parent_options
|
||||
),
|
||||
@@ -391,6 +435,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
prefix: String::new(),
|
||||
marker: String::new(),
|
||||
include_prefix: false,
|
||||
layout: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,7 +63,13 @@ impl SectionEdit {
|
||||
let start = self.start;
|
||||
// Page 1 always starts a section.
|
||||
if self.remove && start != 0 {
|
||||
d.sections.retain(|x| x.start != start);
|
||||
// A section that starts an alternate layout stays (the layout needs it); it just
|
||||
// continues the previous section's numbers.
|
||||
if let Some(sec) = d.sections.iter_mut().find(|x| x.start == start && x.layout.is_some()) {
|
||||
sec.start_number = None;
|
||||
} else {
|
||||
d.sections.retain(|x| x.start != start);
|
||||
}
|
||||
// The pages after it follow the previous section's numbers again.
|
||||
d.repaginate();
|
||||
return;
|
||||
@@ -74,6 +80,7 @@ impl SectionEdit {
|
||||
style: Default::default(),
|
||||
prefix: String::new(),
|
||||
marker: String::new(),
|
||||
layout: None,
|
||||
include_prefix: false,
|
||||
});
|
||||
if let Some(n) = self.start_number {
|
||||
|
||||
+354
-61
@@ -200,79 +200,255 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Alternate Layout…",
|
||||
["Layout"],
|
||||
None,
|
||||
"{name, width, height, rule?: page rule for the copies (default: keep each page's)} — every page copied after the last at the new size (a new section marked `name`), objects following the liquid rules",
|
||||
"{name, width, height, from?: source layout name|index (default: the first), fromName?: names the source layout, rule?: page rule for the copies (default: keep each page's)} → {layout, firstPage, pages} — the source layout's pages copied after the last page at the new size (a layout of their own, its own column in the Pages panel), objects following the liquid rules",
|
||||
has_doc,
|
||||
create_alternate
|
||||
),
|
||||
cmd!(
|
||||
"layout.alternate.duplicate",
|
||||
"Duplicate Alternate Layout",
|
||||
["Layout"],
|
||||
None,
|
||||
"{layout: name|index, name?: (default \"<layout> copy\")} → {layout, firstPage, pages} — the layout's pages and objects copied at the same size",
|
||||
has_doc,
|
||||
duplicate_alternate
|
||||
),
|
||||
cmd!(
|
||||
"layout.alternate.delete",
|
||||
"Delete Alternate Layout",
|
||||
["Layout"],
|
||||
None,
|
||||
"{layout: name|index} — delete the layout's pages and their objects; the last layout cannot be deleted",
|
||||
has_doc,
|
||||
delete_alternate
|
||||
),
|
||||
cmd!(
|
||||
"layout.alternate.options",
|
||||
"Alternate Layout Options…",
|
||||
["Layout"],
|
||||
None,
|
||||
"{layout: name|index, name?, width?, height?} → {layout} — rename the layout, or resize its pages (objects follow the liquid rules)",
|
||||
has_doc,
|
||||
alternate_options
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn create_alternate(s: &mut crate::Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "layout.createAlternate";
|
||||
let name = str_param(p, "name").unwrap_or("Alternate").to_string();
|
||||
let w = p.get("width").and_then(Value::as_f64).ok_or_else(|| bad(ID, "`width` required"))?.max(1.0);
|
||||
let h = p.get("height").and_then(Value::as_f64).ok_or_else(|| bad(ID, "`height` required"))?.max(1.0);
|
||||
let rule = p.get("rule").map(|_| rule_param(p, ID)).transpose()?;
|
||||
let n = s.doc()?.doc.page_count();
|
||||
s.edit(|d, _| {
|
||||
let src = d.clone();
|
||||
let parent = d.page_loc(n - 1).and_then(|(si, pi)| d.spreads[si].pages[pi].parent);
|
||||
d.insert_pages(Some(n - 1), n, parent)?;
|
||||
for i in 0..n {
|
||||
let (Some((si, pi)), Some((ti, tj))) = (src.page_loc(i), d.page_loc(n + i)) else { continue };
|
||||
let from = &src.spreads[si];
|
||||
let pb = from.pages[pi].bounds();
|
||||
let last = from.pages.len() - 1;
|
||||
let ids: Vec<_> = from
|
||||
.items
|
||||
.iter()
|
||||
.filter(|it| {
|
||||
let c = it.bounds().center().x;
|
||||
(pi == 0 || c >= pb.x0) && (pi == last || c < pb.x1)
|
||||
})
|
||||
.map(|it| it.id)
|
||||
.collect();
|
||||
// The copy keeps the page's liquid rule (or takes the one given).
|
||||
let to = d.spreads[ti].pages[tj].bounds();
|
||||
Arc::make_mut(&mut d.spreads[ti]).pages[tj].liquid = rule.unwrap_or(from.pages[pi].liquid);
|
||||
Arc::make_mut(&mut d.spreads[ti]).pages[tj].guides = from.pages[pi]
|
||||
.guides
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|mut g| {
|
||||
if g.orientation == Orientation::Vertical {
|
||||
g.position += to.x0 - pb.x0;
|
||||
}
|
||||
g
|
||||
})
|
||||
.collect();
|
||||
let new = super::object::duplicate_from(d, &src, &ids, SpreadRef::Doc(ti), designcraft_geom::Vec2::new(to.x0 - pb.x0, 0.0))?;
|
||||
// The copies' stories stay linked to the originals (Links panel: update when they change).
|
||||
for (old, new) in ids.iter().zip(&new) {
|
||||
let (Some(os), Some(ns)) =
|
||||
(src.item(*old).and_then(|i| i.text_frame()).map(|t| t.story), d.item(*new).and_then(|i| i.text_frame()).map(|t| t.story))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let rev = src.story(os).map_or(0, |s| s.rev);
|
||||
if let Some(st) = d.story_mut(ns) {
|
||||
st.link = Some((os, rev));
|
||||
}
|
||||
}
|
||||
}
|
||||
let pages: Vec<usize> = (n + 1..=2 * n).collect();
|
||||
super::layout::resize_pages(d, &pages, Some(w), Some(h));
|
||||
d.sections.retain(|x| x.start != n);
|
||||
/// A layout named by a command parameter: its name or its index in [`Document::layouts`].
|
||||
fn layout_param(id: &str, d: &Document, v: Option<&Value>) -> Result<designcraft_doc::LayoutRange> {
|
||||
let layouts = d.layouts();
|
||||
let found = match v {
|
||||
Some(Value::Number(n)) => n.as_u64().and_then(|i| usize::try_from(i).ok()).and_then(|i| layouts.get(i)),
|
||||
Some(Value::String(s)) => layouts.iter().find(|l| l.name == *s),
|
||||
Some(_) => return Err(bad(id, "`layout` is a layout name or index")),
|
||||
None => return Err(bad(id, "`layout` names a layout")),
|
||||
};
|
||||
found.cloned().ok_or_else(|| bad(id, format!("no layout {}", v.map(Value::to_string).unwrap_or_default())))
|
||||
}
|
||||
|
||||
/// A layout name parameter: trimmed, 1–100 characters.
|
||||
fn layout_name(id: &str, v: &str) -> Result<String> {
|
||||
let v = v.trim();
|
||||
if v.is_empty() || v.chars().count() > 100 {
|
||||
return Err(bad(id, "a layout name has 1 to 100 characters"));
|
||||
}
|
||||
Ok(v.to_string())
|
||||
}
|
||||
|
||||
/// `base`, or `base 2`, `base 3`… when another layout (but `except`) has the name.
|
||||
fn unique_layout_name(d: &Document, base: &str, except: Option<usize>) -> String {
|
||||
let taken: Vec<String> = d.layouts().into_iter().enumerate().filter(|(i, _)| Some(*i) != except).map(|(_, l)| l.name).collect();
|
||||
if !taken.iter().any(|t| t == base) {
|
||||
return base.to_string();
|
||||
}
|
||||
(2..=taken.len().saturating_add(2)).map(|k| format!("{base} {k}")).find(|c| !taken.contains(c)).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
|
||||
/// The section starting at absolute page `start`, made when there is none.
|
||||
fn section_at(d: &mut Document, start: usize) -> Option<&mut designcraft_doc::Section> {
|
||||
if !d.sections.iter().any(|x| x.start == start) {
|
||||
d.sections.push(designcraft_doc::Section {
|
||||
start: n,
|
||||
start_number: Some(1),
|
||||
start,
|
||||
start_number: if start == 0 { Some(1) } else { None },
|
||||
style: Default::default(),
|
||||
prefix: String::new(),
|
||||
marker: name.clone(),
|
||||
marker: String::new(),
|
||||
include_prefix: false,
|
||||
layout: None,
|
||||
});
|
||||
d.sections.sort_by_key(|x| x.start);
|
||||
Ok(json!({"firstPage": n + 1, "pages": n}))
|
||||
}
|
||||
d.sections.iter_mut().find(|x| x.start == start)
|
||||
}
|
||||
|
||||
/// Copy the pages of layout `from` after the last page as a new layout `name`, sized `size`
|
||||
/// (None keeps each page's), with `rule` as the copies' page rule. → {layout, firstPage, pages}
|
||||
fn copy_layout(
|
||||
id: &str,
|
||||
d: &mut Document,
|
||||
from: &designcraft_doc::LayoutRange,
|
||||
name: &str,
|
||||
size: Option<(f64, f64)>,
|
||||
rule: Option<LiquidRule>,
|
||||
) -> Result<Value> {
|
||||
let n = d.page_count();
|
||||
let count = from.end.saturating_sub(from.start);
|
||||
if count == 0 || from.end > n {
|
||||
return Err(bad(id, format!("layout {} has no pages", from.name)));
|
||||
}
|
||||
let name = unique_layout_name(d, name, None);
|
||||
let src = d.clone();
|
||||
let last_parent = d.page_loc(n.saturating_sub(1)).and_then(|(si, pi)| d.spreads.get(si).and_then(|sp| sp.pages.get(pi))).and_then(|p| p.parent);
|
||||
d.insert_pages(n.checked_sub(1), count, last_parent)?;
|
||||
// The new layout starts numbering at 1 and a spread of its own.
|
||||
d.sections.retain(|x| x.start != n);
|
||||
d.sections.push(designcraft_doc::Section {
|
||||
start: n,
|
||||
start_number: Some(1),
|
||||
style: Default::default(),
|
||||
prefix: String::new(),
|
||||
marker: name.clone(),
|
||||
include_prefix: false,
|
||||
layout: Some(name.clone()),
|
||||
});
|
||||
d.sections.sort_by_key(|x| x.start);
|
||||
d.repaginate();
|
||||
for k in 0..count {
|
||||
let i = from.start + k;
|
||||
let (Some((si, pi)), Some((ti, tj))) = (src.page_loc(i), d.page_loc(n + k)) else { continue };
|
||||
let Some(from_sp) = src.spreads.get(si) else { continue };
|
||||
let Some(from_pg) = from_sp.pages.get(pi) else { continue };
|
||||
let pb = from_pg.bounds();
|
||||
let last = from_sp.pages.len().saturating_sub(1);
|
||||
let ids: Vec<_> = from_sp
|
||||
.items
|
||||
.iter()
|
||||
.filter(|it| {
|
||||
let c = it.bounds().center().x;
|
||||
(pi == 0 || c >= pb.x0) && (pi == last || c < pb.x1)
|
||||
})
|
||||
.map(|it| it.id)
|
||||
.collect();
|
||||
let Some(sp) = d.spreads.get_mut(ti) else { continue };
|
||||
let sp = Arc::make_mut(sp);
|
||||
let Some(pg) = sp.pages.get_mut(tj) else { continue };
|
||||
// The copy is the page it copies (size, margins, columns, parent, guides), with the
|
||||
// page rule given or its own.
|
||||
let dx = pg.x - from_pg.x;
|
||||
(pg.width, pg.height, pg.margins, pg.columns, pg.parent) =
|
||||
(from_pg.width, from_pg.height, from_pg.margins, from_pg.columns.clone(), from_pg.parent);
|
||||
pg.liquid = rule.unwrap_or(from_pg.liquid);
|
||||
pg.guides = from_pg
|
||||
.guides
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|mut g| {
|
||||
if g.orientation == Orientation::Vertical {
|
||||
g.position += dx;
|
||||
}
|
||||
g
|
||||
})
|
||||
.collect();
|
||||
sp.relayout();
|
||||
let to = sp.pages.get(tj).map_or(pb, |p| p.bounds());
|
||||
let new = super::object::duplicate_from(d, &src, &ids, SpreadRef::Doc(ti), designcraft_geom::Vec2::new(to.x0 - pb.x0, to.y0 - pb.y0))?;
|
||||
// The copies' stories stay linked to the originals (Links panel: update when they change).
|
||||
for (old, new) in ids.iter().zip(&new) {
|
||||
let (Some(os), Some(ns)) =
|
||||
(src.item(*old).and_then(|i| i.text_frame()).map(|t| t.story), d.item(*new).and_then(|i| i.text_frame()).map(|t| t.story))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let rev = src.story(os).map_or(0, |s| s.rev);
|
||||
if let Some(st) = d.story_mut(ns) {
|
||||
st.link = Some((os, rev));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((w, h)) = size {
|
||||
let pages: Vec<usize> = (n + 1..=n + count).collect();
|
||||
super::layout::resize_pages(d, &pages, Some(w), Some(h));
|
||||
}
|
||||
Ok(json!({"layout": name, "firstPage": n + 1, "pages": count}))
|
||||
}
|
||||
|
||||
fn create_alternate(s: &mut crate::Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "layout.createAlternate";
|
||||
let name = layout_name(ID, str_param(p, "name").unwrap_or("Alternate"))?;
|
||||
let size = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(ID, format!("`{k}` is a number")));
|
||||
let (w, h) = (size("width")?.clamp(1.0, 15_552.0), size("height")?.clamp(1.0, 15_552.0));
|
||||
let rule = p.get("rule").map(|_| rule_param(p, ID)).transpose()?;
|
||||
let from_name = str_param(p, "fromName").map(|v| layout_name(ID, v)).transpose()?;
|
||||
let from = match p.get("from") {
|
||||
None | Some(Value::Null) => s.doc()?.doc.layouts().into_iter().next().ok_or_else(|| bad(ID, "no layout"))?,
|
||||
v => layout_param(ID, &s.doc()?.doc, v)?,
|
||||
};
|
||||
s.edit(|d, _| {
|
||||
if let Some(fname) = from_name {
|
||||
let index = d.layouts().iter().position(|l| l.start == from.start);
|
||||
let fname = unique_layout_name(d, &fname, index);
|
||||
if let Some(sec) = section_at(d, from.start) {
|
||||
sec.layout = Some(fname);
|
||||
}
|
||||
}
|
||||
copy_layout(ID, d, &from, &name, Some((w, h)), rule)
|
||||
})
|
||||
}
|
||||
|
||||
fn duplicate_alternate(s: &mut crate::Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "layout.alternate.duplicate";
|
||||
let from = layout_param(ID, &s.doc()?.doc, p.get("layout"))?;
|
||||
let name = match str_param(p, "name") {
|
||||
Some(v) => layout_name(ID, v)?,
|
||||
// Kept within a layout name's 100 characters.
|
||||
None => format!("{} copy", from.name.chars().take(95).collect::<String>()),
|
||||
};
|
||||
s.edit(|d, _| copy_layout(ID, d, &from, &name, None, None))
|
||||
}
|
||||
|
||||
fn delete_alternate(s: &mut crate::Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "layout.alternate.delete";
|
||||
let d = &s.doc()?.doc;
|
||||
let layout = layout_param(ID, d, p.get("layout"))?;
|
||||
if d.layouts().len() < 2 {
|
||||
return Err(bad(ID, "the only layout cannot be deleted"));
|
||||
}
|
||||
let pages: Vec<usize> = (layout.start..layout.end).collect();
|
||||
s.edit(|d, sel| {
|
||||
d.delete_pages(&pages)?;
|
||||
*sel = Default::default();
|
||||
Ok(json!({"layout": layout.name, "pages": pages.len()}))
|
||||
})
|
||||
}
|
||||
|
||||
fn alternate_options(s: &mut crate::Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "layout.alternate.options";
|
||||
let d = &s.doc()?.doc;
|
||||
let layout = layout_param(ID, d, p.get("layout"))?;
|
||||
let index = d.layouts().iter().position(|l| l.start == layout.start);
|
||||
let name = str_param(p, "name").map(|v| layout_name(ID, v)).transpose()?;
|
||||
let dim = |k: &str| -> Result<Option<f64>> {
|
||||
p.get(k)
|
||||
.map(|v| v.as_f64().filter(|v| v.is_finite()).map(|v| v.clamp(1.0, 15_552.0)).ok_or_else(|| bad(ID, format!("`{k}` is a number"))))
|
||||
.transpose()
|
||||
};
|
||||
let (w, h) = (dim("width")?, dim("height")?);
|
||||
if name.as_ref().is_none_or(|n| *n == layout.name) && w.is_none() && h.is_none() {
|
||||
return Ok(json!({"layout": layout.name}));
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
let mut out = layout.name.clone();
|
||||
if let Some(name) = name {
|
||||
out = unique_layout_name(d, &name, index);
|
||||
if let Some(sec) = section_at(d, layout.start) {
|
||||
sec.layout = Some(out.clone());
|
||||
}
|
||||
}
|
||||
if w.is_some() || h.is_some() {
|
||||
let pages: Vec<usize> = (layout.start + 1..=layout.end).collect();
|
||||
super::layout::resize_pages(d, &pages, w, h);
|
||||
}
|
||||
Ok(json!({"layout": out}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -342,4 +518,121 @@ mod tests {
|
||||
let links = s.execute("story.links", &json!({})).unwrap();
|
||||
assert!(links.as_array().unwrap().iter().any(|l| l["parent"] == t["story"]), "{links}");
|
||||
}
|
||||
|
||||
fn layouts(s: &Session) -> Vec<(String, usize, usize)> {
|
||||
s.doc().unwrap().doc.layouts().into_iter().map(|l| (l.name, l.start, l.end)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternate_layouts_copy_one_layout_and_can_be_duplicated_renamed_and_deleted() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 3, "width": 600, "height": 800, "facingPages": true})).unwrap();
|
||||
assert_eq!(layouts(&s), vec![("Layout 1".into(), 0, 3)]);
|
||||
let r = s.execute("layout.createAlternate", &json!({"name": "Tablet H", "width": 1024, "height": 768, "fromName": "Print"})).unwrap();
|
||||
assert_eq!(r["layout"], "Tablet H");
|
||||
assert_eq!(layouts(&s), vec![("Print".into(), 0, 3), ("Tablet H".into(), 3, 6)]);
|
||||
// Again from the first layout: only its 3 pages are copied, and the name is made unique.
|
||||
let r = s.execute("layout.createAlternate", &json!({"name": "Tablet H", "width": 768, "height": 1024, "from": "Print"})).unwrap();
|
||||
assert_eq!(r["layout"], "Tablet H 2");
|
||||
assert_eq!(layouts(&s).last().cloned(), Some(("Tablet H 2".into(), 6, 9)));
|
||||
// Every layout starts a spread of its own.
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
for l in d.layouts() {
|
||||
assert_eq!(d.page_loc(l.start).unwrap().1, 0, "{l:?} starts a spread");
|
||||
}
|
||||
let (si, pi) = d.page_loc(3).unwrap();
|
||||
assert!((d.spreads[si].pages[pi].width - 1024.0).abs() < 1e-6);
|
||||
// Duplicate keeps the size; delete removes the layout's pages; undo brings them back.
|
||||
let r = s.execute("layout.alternate.duplicate", &json!({"layout": "Tablet H"})).unwrap();
|
||||
assert_eq!(r["layout"], "Tablet H copy");
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let (si, pi) = d.page_loc(9).unwrap();
|
||||
assert!((d.spreads[si].pages[pi].width - 1024.0).abs() < 1e-6);
|
||||
assert_eq!(d.page_count(), 12);
|
||||
s.execute("layout.alternate.delete", &json!({"layout": "Tablet H"})).unwrap();
|
||||
assert_eq!(layouts(&s), vec![("Print".into(), 0, 3), ("Tablet H 2".into(), 3, 6), ("Tablet H copy".into(), 6, 9)]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(layouts(&s).len(), 4);
|
||||
// Rename (kept unique) and resize.
|
||||
let r = s.execute("layout.alternate.options", &json!({"layout": 3, "name": "Print"})).unwrap();
|
||||
assert_eq!(r["layout"], "Print 2");
|
||||
s.execute("layout.alternate.options", &json!({"layout": "Print 2", "width": 500})).unwrap();
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let (si, pi) = d.page_loc(9).unwrap();
|
||||
assert!((d.spreads[si].pages[pi].width - 500.0).abs() < 1e-6);
|
||||
// Deleting the first layout leaves the next one first.
|
||||
s.execute("layout.alternate.delete", &json!({"layout": 0})).unwrap();
|
||||
assert_eq!(layouts(&s).first().cloned(), Some(("Tablet H".into(), 0, 3)));
|
||||
// Inserting a page at the end of a layout keeps the next layout's pages its own.
|
||||
s.execute("layout.pages.insert", &json!({"after": 2, "count": 1})).unwrap();
|
||||
assert_eq!(layouts(&s)[0], ("Tablet H".into(), 0, 4));
|
||||
assert_eq!(layouts(&s)[1].1, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternate_layout_commands_refuse_bad_input() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 1})).unwrap();
|
||||
// The only layout cannot be deleted.
|
||||
assert!(s.execute("layout.alternate.delete", &json!({"layout": 0})).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc.page_count(), 1);
|
||||
for p in [json!({}), json!({"layout": "Nope"}), json!({"layout": 7}), json!({"layout": true}), json!({"layout": -1})] {
|
||||
assert!(s.execute("layout.alternate.duplicate", &p).is_err(), "{p}");
|
||||
assert!(s.execute("layout.alternate.options", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(s.execute("layout.alternate.options", &json!({"layout": 0, "name": " "})).is_err());
|
||||
assert!(s.execute("layout.alternate.options", &json!({"layout": 0, "width": "wide"})).is_err());
|
||||
assert!(s.execute("layout.createAlternate", &json!({"name": "", "width": 10, "height": 10})).is_err());
|
||||
assert!(s.execute("layout.createAlternate", &json!({"name": "X", "height": 10})).is_err());
|
||||
assert!(s.execute("layout.createAlternate", &json!({"name": "X", "width": 10, "height": 10, "from": "Nope"})).is_err());
|
||||
assert!(s.doc().unwrap().history.undo.is_empty());
|
||||
// A layout survives Numbering & Section Options › remove section.
|
||||
s.execute("layout.createAlternate", &json!({"name": "Wide", "width": 900, "height": 600})).unwrap();
|
||||
s.execute("layout.section", &json!({"page": 2, "remove": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.layouts().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_layouts_first_page_keeps_the_layout_when_a_section_follows() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
|
||||
s.execute("layout.createAlternate", &json!({"name": "Wide", "width": 900, "height": 600})).unwrap();
|
||||
// The layout starts at page 3; a numbering section starts at page 4.
|
||||
s.execute("layout.section", &json!({"page": 4, "startNumber": 7})).unwrap();
|
||||
s.execute("layout.pages.delete", &json!({"pages": [2]})).unwrap();
|
||||
let names: Vec<(String, usize, usize)> = layouts(&s);
|
||||
assert_eq!(names, vec![("Layout 1".into(), 0, 2), ("Wide".into(), 2, 3)]);
|
||||
// The kept section's numbering stays.
|
||||
assert_eq!(s.doc().unwrap().doc.page_number(2), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layouts_keep_their_pages_when_a_page_moves_across_them() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap();
|
||||
s.execute("layout.createAlternate", &json!({"name": "Wide", "width": 900, "height": 600})).unwrap();
|
||||
assert_eq!(layouts(&s), vec![("Layout 1".into(), 0, 3), ("Wide".into(), 3, 6)]);
|
||||
let wide = |s: &Session, i: usize| (s.doc().unwrap().doc.page(i).unwrap().width - 900.0).abs() < 1e-6;
|
||||
// The first page to the end: it joins the last layout, and Wide keeps its own 3 pages.
|
||||
s.execute("layout.pages.move", &json!({"from": 0, "to": 5})).unwrap();
|
||||
assert_eq!(layouts(&s), vec![("Layout 1".into(), 0, 2), ("Wide".into(), 2, 6)]);
|
||||
assert!((2..5).all(|i| wide(&s, i)) && !wide(&s, 5) && !wide(&s, 1));
|
||||
// Wide's first page to the front: Wide starts at its next page.
|
||||
s.execute("layout.pages.move", &json!({"from": 2, "to": 0})).unwrap();
|
||||
assert_eq!(layouts(&s), vec![("Layout 1".into(), 0, 3), ("Wide".into(), 3, 6)]);
|
||||
assert!(wide(&s, 0) && (3..5).all(|i| wide(&s, i)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layouts_survive_a_save_and_old_sections_load() {
|
||||
let sec: designcraft_doc::Section = serde_json::from_value(json!({"start": 2, "marker": "x"})).unwrap();
|
||||
assert_eq!(sec.layout, None);
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 1})).unwrap();
|
||||
s.execute("layout.createAlternate", &json!({"name": "Wide", "width": 900, "height": 600})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let back: Vec<designcraft_doc::Section> = serde_json::from_value(serde_json::to_value(&d.sections).unwrap()).unwrap();
|
||||
assert_eq!(back, d.sections);
|
||||
assert!(back.iter().any(|x| x.layout.as_deref() == Some("Wide")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Print…",
|
||||
["File"],
|
||||
Some("Cmd+P"),
|
||||
"{printer?, copies?: 1, pages?: \"1-3,5\", dryRun?: bool (export and preview submission without printing), …the PDF settings of file.exportPdf (the PDF it writes is what prints)} → {printer, copies, pages, command, bytes, warnings}",
|
||||
"{printer?, copies?: 1, pages?: \"1-3,5\", layouts?: [layout names] (their pages; instead of `pages`), separateJobs?: bool (a print job per layout, titled after it), dryRun?: bool (export and preview submission without printing), …the PDF settings of file.exportPdf (the PDF it writes is what prints)} → {printer, copies, pages, command, bytes, warnings, jobs}",
|
||||
has_doc,
|
||||
print
|
||||
),
|
||||
@@ -43,28 +43,65 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn print_with(
|
||||
s: &mut Session,
|
||||
p: &Value,
|
||||
submit: impl FnOnce(&[u8], &craft_print::Job) -> std::result::Result<String, craft_print::Error>,
|
||||
mut submit: impl FnMut(&[u8], &craft_print::Job) -> std::result::Result<String, craft_print::Error>,
|
||||
) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let opts = super::export::options(p, d.page_count())?;
|
||||
let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999) as u32;
|
||||
let job = craft_print::Job {
|
||||
printer: str_param(p, "printer").map(str::to_string),
|
||||
copies,
|
||||
scaling: craft_print::Scaling::ShrinkToFit,
|
||||
title: d.title.clone(),
|
||||
..craft_print::Job::default()
|
||||
// Layouts: their pages, in one job or a job each (`None`: the pages `p` names).
|
||||
let jobs: Vec<(String, Option<Vec<usize>>)> = match p.get("layouts") {
|
||||
None | Some(Value::Null) => vec![(d.title.clone(), None)],
|
||||
Some(v) => {
|
||||
let names: Vec<String> = serde_json::from_value(v.clone()).map_err(|_| bad("file.print", "`layouts` is a list of layout names"))?;
|
||||
let groups = super::export_batch::layout_pages("file.print", d, &names)?;
|
||||
if p.get("separateJobs").and_then(Value::as_bool).unwrap_or(false) {
|
||||
groups.into_iter().map(|(name, pages)| (format!("{} - {name}", d.title), Some(pages))).collect()
|
||||
} else {
|
||||
vec![(d.title.clone(), Some(groups.into_iter().flat_map(|(_, p)| p).collect()))]
|
||||
}
|
||||
}
|
||||
};
|
||||
let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999) as u32;
|
||||
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
|
||||
let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len());
|
||||
// The PDF that Export PDF writes with these settings.
|
||||
let pdf = super::export::pdf_export(s, p)?;
|
||||
let command = craft_print::command_preview(&job).map_err(|e| bad("file.print", e.to_string()))?;
|
||||
let out =
|
||||
json!({"printer": job.printer, "copies": copies, "pages": pages, "command": command, "bytes": pdf.bytes.len(), "warnings": pdf.warnings});
|
||||
if !dry {
|
||||
submit(&pdf.bytes, &job).map_err(|e| bad("file.print", e.to_string()))?;
|
||||
let mut out = Value::Null;
|
||||
let (mut total_pages, mut total_bytes) = (0usize, 0usize);
|
||||
let (mut titles, mut warnings) = (Vec::new(), Vec::<String>::new());
|
||||
// Every job's PDF first, so a failing one sends nothing.
|
||||
let mut ready = Vec::new();
|
||||
for (title, pages) in jobs {
|
||||
let job = craft_print::Job {
|
||||
printer: str_param(p, "printer").map(str::to_string),
|
||||
copies,
|
||||
scaling: craft_print::Scaling::ShrinkToFit,
|
||||
title: title.clone(),
|
||||
..craft_print::Job::default()
|
||||
};
|
||||
// The PDF that Export PDF writes with these settings, on the job's pages.
|
||||
let mut params = p.clone();
|
||||
if let (Some(pages), Some(o)) = (&pages, params.as_object_mut()) {
|
||||
o.insert("pages".into(), json!(pages.iter().map(|p| p.saturating_add(1)).collect::<Vec<_>>()));
|
||||
}
|
||||
let pdf = super::export::pdf_export(s, ¶ms)?;
|
||||
let command = craft_print::command_preview(&job).map_err(|e| bad("file.print", e.to_string()))?;
|
||||
total_pages += pages.as_ref().or(opts.pages.as_ref()).map_or(d.page_count(), Vec::len);
|
||||
total_bytes += pdf.bytes.len();
|
||||
for w in pdf.warnings {
|
||||
if !warnings.contains(&w) {
|
||||
warnings.push(w);
|
||||
}
|
||||
}
|
||||
out = json!({"printer": job.printer, "copies": copies, "command": command});
|
||||
titles.push(title);
|
||||
ready.push((pdf.bytes, job));
|
||||
}
|
||||
if !dry {
|
||||
for (pdf, job) in &ready {
|
||||
submit(pdf, job).map_err(|e| bad("file.print", e.to_string()))?;
|
||||
}
|
||||
}
|
||||
out["pages"] = json!(total_pages);
|
||||
out["bytes"] = json!(total_bytes);
|
||||
out["warnings"] = json!(warnings);
|
||||
out["jobs"] = json!(titles);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -159,4 +196,29 @@ mod tests {
|
||||
}
|
||||
assert!(matched, "File › Print spools Export PDF's bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_layouts_together_or_as_a_job_each() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 2, "facingPages": false, "title": "Mag"})).unwrap();
|
||||
s.execute("layout.createAlternate", &json!({"name": "Wide", "width": 900, "height": 600})).unwrap();
|
||||
let mut sent = Vec::new();
|
||||
let r = print_with(&mut s, &json!({"layouts": ["Wide"]}), |pdf, job| {
|
||||
sent.push((designcraft_render::pdf_page_count(pdf), job.title.clone()));
|
||||
Ok(String::new())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(r["pages"], 2);
|
||||
assert_eq!(sent, [(Some(2), "Mag".to_string())]);
|
||||
sent.clear();
|
||||
let r = print_with(&mut s, &json!({"layouts": [], "separateJobs": true}), |pdf, job| {
|
||||
sent.push((designcraft_render::pdf_page_count(pdf), job.title.clone()));
|
||||
Ok(String::new())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(r["jobs"], json!(["Mag - Layout 1", "Mag - Wide"]));
|
||||
assert_eq!(sent, [(Some(2), "Mag - Layout 1".to_string()), (Some(2), "Mag - Wide".to_string())]);
|
||||
assert!(print_with(&mut s, &json!({"layouts": ["Nope"]}), |_, _| panic!("unknown layouts must not spool")).is_err());
|
||||
assert!(print_with(&mut s, &json!({"layouts": "Wide"}), |_, _| panic!("bad layouts must not spool")).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +276,7 @@ fn same_component(a: Component, b: Component) -> bool {
|
||||
}
|
||||
|
||||
/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file.
|
||||
fn is_device_name(component: &str) -> bool {
|
||||
pub(crate) fn is_device_name(component: &str) -> bool {
|
||||
let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase();
|
||||
let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"));
|
||||
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT")
|
||||
|
||||
@@ -102,6 +102,15 @@ pub fn read_to_string(path: impl AsRef<Path>) -> Result<String, String> {
|
||||
std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Make the folder at `path` and the folders above it, inside the automation write root when
|
||||
/// there is one.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn create_dir_all(path: impl AsRef<Path>) -> Result<(), String> {
|
||||
let path = path.as_ref();
|
||||
crate::file_access::check_write(path)?;
|
||||
std::fs::create_dir_all(path).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Write `bytes` to the file at `path`, inside the automation write root when there is one.
|
||||
pub fn write_file(path: impl AsRef<Path>, bytes: &[u8]) -> Result<(), String> {
|
||||
let path = path.as_ref();
|
||||
|
||||
@@ -243,6 +243,8 @@ pub struct Prefs {
|
||||
pub recovery_minutes: f64,
|
||||
/// File › New › Document › Save Document Preset: saved document presets, by name (`title`).
|
||||
pub document_presets: Vec<designcraft_doc::build::NewDocument>,
|
||||
/// File › Export Layouts and Versions › Save Preset: saved export jobs, by name.
|
||||
pub export_presets: Vec<cmd::export_batch::ExportPreset>,
|
||||
/// Preferences › File Handling › Create Links When Placing Text Files: placed text files are
|
||||
/// linked (Links panel) unless `file.place` says otherwise.
|
||||
pub link_text_files: bool,
|
||||
@@ -353,6 +355,7 @@ impl Default for Prefs {
|
||||
fn default() -> Self {
|
||||
Prefs {
|
||||
document_presets: Vec::new(),
|
||||
export_presets: Vec::new(),
|
||||
show_hidden_characters: false,
|
||||
typographers_quotes: true,
|
||||
polygon_sides: 6,
|
||||
|
||||
@@ -690,3 +690,20 @@ fn idml_non_finite_numbers_import_to_a_document_that_reopens() {
|
||||
}
|
||||
assert!(failures.is_empty(), "{failures:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternate_layouts_round_trip_through_idml() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
|
||||
s.execute("layout.createAlternate", &json!({"name": "Tablet H", "width": 1024, "height": 768, "fromName": "Print"})).unwrap();
|
||||
s.execute("layout.section", &json!({"page": 4, "startNumber": 9})).unwrap();
|
||||
let names = |d: &designcraft_doc::Document| d.layouts().into_iter().map(|l| (l.name, l.start, l.end)).collect::<Vec<_>>();
|
||||
let before = names(&s.doc().unwrap().doc);
|
||||
let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap();
|
||||
assert_eq!(names(&back), before);
|
||||
// A document without alternate layouts writes none.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 2})).unwrap();
|
||||
let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap();
|
||||
assert!(back.sections.iter().all(|x| x.layout.is_none()));
|
||||
}
|
||||
|
||||
@@ -117,10 +117,14 @@ impl<'a> Ex<'a> {
|
||||
prefix: String::new(),
|
||||
marker: String::new(),
|
||||
include_prefix: false,
|
||||
layout: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
let total = d.page_count();
|
||||
// Alternate layouts: every section names the layout it is in (InDesign's AlternateLayout).
|
||||
let layouts = d.layouts();
|
||||
let alternates = layouts.len() > 1 || secs.iter().any(|s| s.layout.is_some());
|
||||
for (i, s) in secs.iter().enumerate() {
|
||||
if s.start >= total {
|
||||
continue;
|
||||
@@ -137,6 +141,9 @@ impl<'a> Ex<'a> {
|
||||
el.set("PageNumberStart", n);
|
||||
}
|
||||
el = el.attr("Marker", &s.marker).attr("PageStart", uid(page.id.0)).attr("SectionPrefix", &s.prefix);
|
||||
if alternates && let Some(l) = layouts.iter().rfind(|l| l.start <= s.start) {
|
||||
el = el.attr("AlternateLayout", &l.name).attr("AlternateLayoutLength", l.end.saturating_sub(l.start));
|
||||
}
|
||||
root.push(with_props(el, vec![p("PageNumberStyle", "enumeration", names::number_style_out(s.style))]));
|
||||
}
|
||||
if !self.index_topics.is_empty() || !self.index_xrefs.is_empty() {
|
||||
|
||||
@@ -540,9 +540,14 @@ impl<'r> Importer<'r> {
|
||||
let sp = self.spread(e, id, false);
|
||||
self.spreads.push(sp);
|
||||
}
|
||||
// Sections.
|
||||
// Sections. A section whose AlternateLayout differs from the one before starts that layout.
|
||||
let mut last_layout: Option<String> = None;
|
||||
for e in top.iter().filter(|e| e.local() == "Section") {
|
||||
let Some(start) = e.get("PageStart").and_then(|p| self.page_index.get(p)).copied() else { continue };
|
||||
let layout = e.get("AlternateLayout").map(str::trim).filter(|l| !l.is_empty()).map(str::to_string);
|
||||
let starts_layout = layout.clone().filter(|l| last_layout.as_ref() != Some(l));
|
||||
// A section without one stays in the layout before it.
|
||||
last_layout = layout.or(last_layout.take());
|
||||
let cont = e.get("ContinueNumbering") != Some("false");
|
||||
self.sections.push(Section {
|
||||
start,
|
||||
@@ -551,6 +556,7 @@ impl<'r> Importer<'r> {
|
||||
prefix: e.get("SectionPrefix").unwrap_or("").to_string(),
|
||||
marker: e.get("Marker").unwrap_or("").to_string(),
|
||||
include_prefix: e.get("IncludeSectionPrefix") == Some("true"),
|
||||
layout: starts_layout,
|
||||
});
|
||||
}
|
||||
if self.rotated_cells > 0 {
|
||||
@@ -570,6 +576,7 @@ impl<'r> Importer<'r> {
|
||||
style: Default::default(),
|
||||
prefix: String::new(),
|
||||
marker: String::new(),
|
||||
layout: None,
|
||||
include_prefix: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -62,7 +62,7 @@ pub(crate) struct Held {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
enum Step {
|
||||
/// Open: run `then` with the file chosen.
|
||||
/// Open (or a folder): run `then` with the file or folder chosen.
|
||||
Open(Then),
|
||||
/// Save: `ext` is added to a name typed without it, then `then` runs, after the replace-file
|
||||
/// question when the added extension names a file that exists.
|
||||
@@ -118,6 +118,31 @@ impl DesignApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask for a folder, the dialog titled `title` (File › Export Layouts and Versions). `then`
|
||||
/// runs with its path on a later frame; Cancel runs nothing.
|
||||
#[track_caller]
|
||||
pub(crate) fn ask_folder(
|
||||
&mut self,
|
||||
title: &str,
|
||||
bound: Bound,
|
||||
then: impl FnOnce(&mut DesignApp, String) -> Result<Value, String> + 'static,
|
||||
) -> Result<Value, String> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let mut dialog = craft_dialogs::FileDialog::folder().title(title);
|
||||
if let Some(folder) = self.session.active().and_then(|d| d.path.as_deref()).and_then(folder_of) {
|
||||
dialog = dialog.directory(folder);
|
||||
}
|
||||
self.ask_file(dialog, bound, Step::Open(Box::new(then)));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (title, bound, then);
|
||||
Err(NO_PICKER.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask where to save `name`: beside the active document once it has been saved, else in the
|
||||
/// folder of the last file saved or exported (#1653). `then` runs with the path on a later
|
||||
/// frame; Cancel runs nothing. In a browser the name is the download's and `then` runs now.
|
||||
|
||||
@@ -794,6 +794,31 @@ fn print_dialog(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
text_field(ui, d, "pages", 120.0);
|
||||
});
|
||||
// Alternate layouts: print chosen layouts, together or as a job each.
|
||||
let layouts: Vec<String> = app.session.active().map(|st| st.doc.layouts().into_iter().map(|l| l.name).collect()).unwrap_or_default();
|
||||
if layouts.len() > 1 {
|
||||
if ui.radio(range == "layouts", crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Layouts:"))).clicked() {
|
||||
d.fields.insert("range".into(), json!("layouts"));
|
||||
}
|
||||
ui.add_enabled_ui(range == "layouts", |ui| {
|
||||
let mut chosen: Vec<String> =
|
||||
d.fields.get("layouts").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_else(|| layouts.clone());
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for name in &layouts {
|
||||
let mut on = chosen.contains(name);
|
||||
if ui.checkbox(&mut on, name.as_str()).changed() {
|
||||
if on {
|
||||
chosen.push(name.clone());
|
||||
} else {
|
||||
chosen.retain(|x| x != name);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
d.fields.insert("layouts".into(), json!(layouts.iter().filter(|n| chosen.contains(n)).collect::<Vec<_>>()));
|
||||
check(ui, d, "separateJobs", crate::i18n::tr(&app.ui.language, "Print each layout separately"));
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
@@ -804,6 +829,9 @@ fn print_dialog(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
if let Some(st) = d.fields.get("status").and_then(Value::as_str) {
|
||||
crate::rtl::label(ui, egui::RichText::new(st).color(ui.visuals().error_fg_color));
|
||||
}
|
||||
}
|
||||
|
||||
/// The presets offered by the Export PDF dialog, in display order. The stored value is the
|
||||
@@ -904,6 +932,20 @@ fn pdf_export(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.horizontal(|ui| {
|
||||
crate::rtl::label(ui, crate::i18n::tr(lang, "Page Range:"));
|
||||
text_field(ui, d, "pages", 160.0);
|
||||
// Alternate layouts: one click puts a layout's pages in the range.
|
||||
let layouts = app.session.active().map(|st| st.doc.layouts()).unwrap_or_default();
|
||||
if layouts.len() > 1 {
|
||||
craft_ui::ComboBox::from_id_salt("pdf_layout").selected_text(crate::i18n::tr(lang, "Layout…")).width(120.0).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(false, crate::i18n::tr(lang, "All")).clicked() {
|
||||
d.fields.insert("pages".into(), json!("all"));
|
||||
}
|
||||
for l in &layouts {
|
||||
if ui.selectable_label(false, &l.name).clicked() {
|
||||
d.fields.insert("pages".into(), json!(format!("{}-{}", l.start.saturating_add(1), l.end)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
check(ui, d, "spreads", crate::i18n::tr(lang, "Spreads"));
|
||||
|
||||
@@ -2940,6 +2982,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
"closeDocument" => crate::i18n::tr(&app.ui.language, "Unsaved Changes"),
|
||||
"deletePages" => crate::i18n::tr(&app.ui.language, "Warning"),
|
||||
"pagesPanelOptions" => crate::i18n::tr(&app.ui.language, "Panel Options"),
|
||||
"parentOptions" => crate::i18n::tr(&app.ui.language, "Parent Options"),
|
||||
"updateLink" => crate::i18n::tr(&app.ui.language, "Update Link"),
|
||||
"menus" => crate::i18n::tr(&app.ui.language, "Menu Customization"),
|
||||
"importOptions" => crate::i18n::tr(&app.ui.language, "Import Options"),
|
||||
@@ -2958,6 +3001,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
"colorGroupOptions" => crate::i18n::tr(&app.ui.language, "Color Group Options"),
|
||||
"layerSettings" => crate::i18n::tr(&app.ui.language, "Layer Options"),
|
||||
crate::effects_dialog::ID => crate::i18n::tr(&app.ui.language, "Effects"),
|
||||
crate::export_batch_dialog::ID => crate::i18n::tr(&app.ui.language, "Export Layouts and Versions"),
|
||||
crate::package_dialog::ID => crate::i18n::tr(&app.ui.language, "Package"),
|
||||
crate::package_dialog::INSTRUCTIONS => crate::i18n::tr(&app.ui.language, "Printing Instructions"),
|
||||
id => match id.strip_prefix("cmd:").and_then(designcraft_engine::find_command) {
|
||||
@@ -2973,13 +3017,15 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
let available = (ctx.content_rect().size() - frame_margin - egui::vec2(16.0, 16.0)).max(egui::Vec2::splat(1.0));
|
||||
let preferred_width: f32 = match d.id.as_str() {
|
||||
"alert" => 440.0,
|
||||
"closeDocument" | "updateLink" | "deletePages" | "newWorkspace" | "pagesPanelOptions" | "insertTable" | "convertTableToText" => 380.0,
|
||||
"closeDocument" | "updateLink" | "deletePages" | "newWorkspace" | "pagesPanelOptions" | "parentOptions" | "insertTable"
|
||||
| "convertTableToText" => 380.0,
|
||||
"insertPages" => 440.0,
|
||||
"convertTextToTable" => 420.0,
|
||||
"newDocument" => 960.0,
|
||||
"hyphenation" => 560.0,
|
||||
"numberingSection" => 560.0,
|
||||
crate::effects_dialog::ID => 720.0,
|
||||
crate::export_batch_dialog::ID => 660.0,
|
||||
"colorPicker" => 580.0,
|
||||
crate::package_dialog::ID => 720.0,
|
||||
crate::package_dialog::INSTRUCTIONS => 520.0,
|
||||
@@ -3326,6 +3372,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
"colorGroupOptions" => options::color_group_options(app, ui, &mut d),
|
||||
"layerSettings" => options::layer_options(app, ui, &mut d),
|
||||
crate::effects_dialog::ID => crate::effects_dialog::show(app, ui, &mut d),
|
||||
crate::export_batch_dialog::ID => crate::export_batch_dialog::show(app, ui, &mut d),
|
||||
crate::package_dialog::ID => crate::package_dialog::show(app, ui, &mut d),
|
||||
crate::package_dialog::INSTRUCTIONS => crate::package_dialog::show_instructions(app, ui, &mut d),
|
||||
"preferences" => preferences(app, ui, &mut d, body_height),
|
||||
@@ -3591,6 +3638,34 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
combo(ui, &mut d, "resize", &resizes);
|
||||
});
|
||||
}
|
||||
"parentOptions" => {
|
||||
// As InDesign's: Prefix, Name, Based on Parent (Number of Pages is the
|
||||
// parent spread's own).
|
||||
let lang = app.ui.language.clone();
|
||||
egui::Grid::new("parent_options").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||||
crate::rtl::label(ui, crate::i18n::tr(&lang, "Prefix:"));
|
||||
text_field(ui, &mut d, "prefix", 60.0);
|
||||
ui.end_row();
|
||||
crate::rtl::label(ui, crate::i18n::tr(&lang, "Name:"));
|
||||
text_field(ui, &mut d, "name", 200.0);
|
||||
ui.end_row();
|
||||
crate::rtl::label(ui, crate::i18n::tr(&lang, "Based on Parent:"));
|
||||
let bases: Vec<(String, String)> = d
|
||||
.fields
|
||||
.get("bases")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|b| Some((b.get(0)?.as_str()?.to_string(), b.get(1)?.as_str()?.to_string()))).collect())
|
||||
.unwrap_or_default();
|
||||
let none = crate::i18n::tr(&lang, "[None]").to_string();
|
||||
let mut opts: Vec<(&str, &str)> = vec![("", none.as_str())];
|
||||
opts.extend(bases.iter().map(|(v, l)| (v.as_str(), l.as_str())));
|
||||
combo(ui, &mut d, "basedOn", &opts);
|
||||
ui.end_row();
|
||||
});
|
||||
if let Some(st) = d.fields.get("status").and_then(Value::as_str) {
|
||||
crate::rtl::label(ui, st);
|
||||
}
|
||||
}
|
||||
"updateLink" => {
|
||||
let text = crate::i18n::tr(
|
||||
&app.ui.language,
|
||||
@@ -3786,6 +3861,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
"updateLink" => crate::i18n::tr(&app.ui.language, " Update ").to_string(),
|
||||
"findFont" => crate::i18n::tr(&app.ui.language, "Replace All").to_string(),
|
||||
"newDocument" => format!(" {} ", crate::i18n::tr(&app.ui.language, "Create")),
|
||||
crate::export_batch_dialog::ID => format!(" {} ", crate::i18n::tr(&app.ui.language, "Export…")),
|
||||
crate::package_dialog::ID => crate::i18n::tr(&app.ui.language, "Package…").to_string(),
|
||||
crate::package_dialog::INSTRUCTIONS => crate::i18n::tr(&app.ui.language, "Continue").to_string(),
|
||||
_ => crate::i18n::tr(&app.ui.language, " OK ").to_string(),
|
||||
@@ -4638,13 +4714,33 @@ fn confirm_part3(app: &mut DesignApp, d: Dialog) -> Result<Value, String> {
|
||||
app.place_file(json!({"path": d.s("path"), "pdfPage": page, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }}))
|
||||
}
|
||||
"print" => {
|
||||
let pages = if d.s("range") == "all" { Value::Null } else { json!(d.s("pages")) };
|
||||
let pages = if d.s("range") == "range" { json!(d.s("pages")) } else { Value::Null };
|
||||
let printer = d.s("printer");
|
||||
let r = app.run(
|
||||
"file.print",
|
||||
json!({"printer": if printer.is_empty() { Value::Null } else { json!(printer) }, "copies": d.n("copies").unwrap_or(1.0).max(1.0) as u64,
|
||||
"pages": pages, "spreads": d.b("spreads"), "marks": d.b("marks"), "bleed": d.b("bleed")}),
|
||||
)?;
|
||||
let mut params = json!({"printer": if printer.is_empty() { Value::Null } else { json!(printer) }, "copies": d.n("copies").unwrap_or(1.0).max(1.0) as u64,
|
||||
"pages": pages, "spreads": d.b("spreads"), "marks": d.b("marks"), "bleed": d.b("bleed")});
|
||||
if d.s("range") == "layouts" {
|
||||
let chosen = d.fields.get("layouts").cloned().unwrap_or_else(|| json!([]));
|
||||
if chosen.as_array().is_some_and(Vec::is_empty) {
|
||||
// Keep the dialog open, saying why.
|
||||
let e = crate::i18n::tr(&app.ui.language, "Choose at least one layout.").to_string();
|
||||
let mut d = d.clone();
|
||||
d.fields.insert("status".into(), json!(e));
|
||||
app.ui.dialog = Some(d);
|
||||
return Err(e);
|
||||
}
|
||||
params["layouts"] = chosen;
|
||||
params["separateJobs"] = json!(d.b("separateJobs"));
|
||||
}
|
||||
let r = match app.run("file.print", params) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
// Keep the dialog (and the layouts chosen) open, saying why.
|
||||
let mut d = d;
|
||||
d.fields.insert("status".into(), json!(e));
|
||||
app.ui.dialog = Some(d);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let printer = r["printer"].as_str().unwrap_or_else(|| crate::i18n::tr(&app.ui.language, "the default printer"));
|
||||
app.status(
|
||||
crate::i18n::tr(&app.ui.language, "Sent {count} page(s) to {printer}")
|
||||
@@ -4870,6 +4966,19 @@ fn confirm_part5(app: &mut DesignApp, d: Dialog) -> Result<Value, String> {
|
||||
}
|
||||
app.run("layout.pages.delete", json!({"pages": d.fields.get("pages").cloned().unwrap_or_else(|| json!([]))}))
|
||||
}
|
||||
"parentOptions" => {
|
||||
let base = d.s("basedOn");
|
||||
let base = base.parse::<u64>().map_or(Value::Null, |id| json!(id));
|
||||
let parent = d.fields.get("parent").cloned().unwrap_or(Value::Null);
|
||||
let r = app.run("layout.parents.options", json!({"parent": parent, "prefix": d.s("prefix"), "name": d.s("name"), "basedOn": base}));
|
||||
if let Err(e) = &r {
|
||||
// A refused prefix or name: the dialog stays, with what was typed, saying why.
|
||||
let mut d = d;
|
||||
d.fields.insert("status".into(), json!(e));
|
||||
app.ui.dialog = Some(d);
|
||||
}
|
||||
r
|
||||
}
|
||||
"pagesPanelOptions" => {
|
||||
use crate::panels::pages::{IconSize, PanelResize};
|
||||
let o = &mut app.ui.pages_panel;
|
||||
@@ -4999,6 +5108,7 @@ fn confirm_part6(app: &mut DesignApp, d: Dialog) -> Result<Value, String> {
|
||||
}
|
||||
"alert" => Ok(Value::Null),
|
||||
crate::effects_dialog::ID => crate::effects_dialog::confirm(app, &d),
|
||||
crate::export_batch_dialog::ID => crate::export_batch_dialog::confirm(app, &d),
|
||||
crate::package_dialog::ID | crate::package_dialog::INSTRUCTIONS => {
|
||||
let r = if d.id == crate::package_dialog::ID {
|
||||
crate::package_dialog::confirm(app, &d)
|
||||
@@ -5080,6 +5190,9 @@ fn confirm_part6(app: &mut DesignApp, d: Dialog) -> Result<Value, String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if cid == "layout.alternate.options" {
|
||||
drop_unchanged_size(&d, &mut p);
|
||||
}
|
||||
let r = app.run(cid, Value::Object(p));
|
||||
if let Err(e) = &r {
|
||||
// Keep the dialog open with the error.
|
||||
@@ -8263,6 +8376,23 @@ fn command_form(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Alternate Layout Options: the size fields start at the layout's first page's size (`sizeWas`),
|
||||
/// and one left as it was isn't sent: it would resize every page of the layout to it.
|
||||
fn drop_unchanged_size(d: &Dialog, p: &mut Map<String, Value>) {
|
||||
let num = |v: Option<&Value>| match v {
|
||||
Some(Value::Number(n)) => n.as_f64(),
|
||||
Some(Value::String(s)) => s.trim().parse::<f64>().ok(),
|
||||
_ => None,
|
||||
};
|
||||
let was = d.fields.get("sizeWas").and_then(Value::as_array);
|
||||
for (k, key) in ["width", "height"].into_iter().enumerate() {
|
||||
let before = num(was.and_then(|a| a.get(k)));
|
||||
if num(p.get(key)).zip(before).is_some_and(|(now, before)| (now - before).abs() < 1e-6) {
|
||||
p.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const STEP_AND_REPEAT: &str = "cmd:edit.stepAndRepeat";
|
||||
pub(crate) const CORNER_OPTIONS: &str = "cmd:object.cornerOptions";
|
||||
const TOC: &str = "cmd:toc.generate";
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
//! File › Export Layouts and Versions…: pick the layouts, the versions (each a format and its
|
||||
//! settings, written to a folder of its own) and the file names, see the files it will write,
|
||||
//! and keep the setup as a preset.
|
||||
|
||||
use designcraft_engine::cmd::export_batch::{ExportFormat, ExportJob, ExportPreset, ExportVersion, builtin_presets, version_templates};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::DesignApp;
|
||||
use crate::app::file_dialogs::Bound;
|
||||
use crate::dialogs::Dialog;
|
||||
|
||||
pub const ID: &str = "exportBatch";
|
||||
/// The most files the preview lists.
|
||||
const PREVIEW_ROWS: usize = 200;
|
||||
|
||||
/// Open the dialog: the last job used (or the Print + Screen preset), on `layouts` when given.
|
||||
pub fn open(app: &mut DesignApp, layouts: Option<Vec<String>>) {
|
||||
let names: Vec<String> = app.session.active().map(|st| st.doc.layouts().into_iter().map(|l| l.name).collect()).unwrap_or_default();
|
||||
let (mut job, preset) = match app.ui.export_batch.clone() {
|
||||
Some(j) => (j, String::new()),
|
||||
None => {
|
||||
let p = builtin_presets().into_iter().next().unwrap_or_default();
|
||||
(p.job, p.name)
|
||||
}
|
||||
};
|
||||
if let Some(l) = layouts {
|
||||
job.layouts = l;
|
||||
}
|
||||
job.layouts.retain(|l| names.contains(l));
|
||||
if job.layouts.len() == names.len() {
|
||||
job.layouts.clear();
|
||||
}
|
||||
app.ui.dialog = Some(Dialog::new(ID, json!({"job": job, "preset": preset})));
|
||||
}
|
||||
|
||||
fn job_of(d: &Dialog) -> ExportJob {
|
||||
d.fields.get("job").and_then(|j| serde_json::from_value(j.clone()).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn presets(app: &DesignApp) -> Vec<ExportPreset> {
|
||||
builtin_presets().into_iter().chain(app.session.prefs.export_presets.iter().cloned()).collect()
|
||||
}
|
||||
|
||||
/// A PDF version's settings as one of the quick choices, or "custom".
|
||||
fn pdf_kind(v: &ExportVersion) -> &'static str {
|
||||
let m = v.marks;
|
||||
let no_marks = !m.crop && !m.bleed && !m.page_info;
|
||||
let plain = v.standard == "none" && !v.spreads;
|
||||
match (v.bleed, m.crop && m.bleed && m.page_info, no_marks, v.compress_images, plain) {
|
||||
(true, true, _, false, true) => "print",
|
||||
(true, false, true, false, true) => "bleed",
|
||||
(false, false, true, true, true) => "screen",
|
||||
_ => "custom",
|
||||
}
|
||||
}
|
||||
|
||||
fn set_pdf_kind(v: &mut ExportVersion, kind: &str) {
|
||||
let marks = designcraft_engine::cmd::export_batch::VersionMarks { crop: true, bleed: true, page_info: true };
|
||||
let none = designcraft_engine::cmd::export_batch::VersionMarks::default();
|
||||
(v.bleed, v.marks, v.compress_images, v.standard, v.spreads) = match kind {
|
||||
"print" => (true, marks, false, "none".into(), false),
|
||||
"bleed" => (true, none, false, "none".into(), false),
|
||||
"screen" => (false, none, true, "none".into(), false),
|
||||
_ => return,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn show(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let lang = app.ui.language.clone();
|
||||
let tr = |s: &str| crate::i18n::tr(&lang, s).to_string();
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let mut job = job_of(d);
|
||||
let before = job.clone();
|
||||
let layout_names: Vec<String> = app.session.active().map(|st| st.doc.layouts().into_iter().map(|l| l.name).collect()).unwrap_or_default();
|
||||
let section = |ui: &mut egui::Ui, title: &str| {
|
||||
ui.add_space(6.0);
|
||||
crate::rtl::label(ui, egui::RichText::new(tr(title)).strong());
|
||||
};
|
||||
|
||||
// ---- Preset.
|
||||
let all = presets(app);
|
||||
let mut preset = d.s("preset");
|
||||
ui.horizontal(|ui| {
|
||||
crate::rtl::label(ui, tr("Preset:"));
|
||||
let shown = if preset.is_empty() { tr("[Custom]") } else { preset.clone() };
|
||||
craft_ui::ComboBox::from_id_salt("export_batch_preset").selected_text(shown).width(200.0).show_ui(ui, |ui| {
|
||||
for p in &all {
|
||||
if ui.selectable_label(p.name == preset, &p.name).clicked() {
|
||||
job = p.job.clone();
|
||||
preset = p.name.clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
let saved = app.session.prefs.export_presets.iter().any(|p| p.name == preset);
|
||||
if saved && ui.button(crate::rtl::widget(ui, tr("Delete Preset"))).clicked() {
|
||||
match app.run("file.deleteExportPreset", json!({"name": preset})) {
|
||||
Ok(_) => preset.clear(),
|
||||
Err(e) => {
|
||||
d.fields.insert("status".into(), json!(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
let mut name = d.s("presetName");
|
||||
let field = ui.add(egui::TextEdit::singleline(&mut name).desired_width(200.0).hint_text(tr("Preset name")));
|
||||
if field.changed() {
|
||||
d.fields.insert("presetName".into(), json!(name));
|
||||
}
|
||||
if ui.add_enabled(!name.trim().is_empty(), egui::Button::new(crate::rtl::widget(ui, tr("Save Preset")))).clicked() {
|
||||
match app.run("file.saveExportPreset", json!({"name": name.trim(), "job": job})) {
|
||||
Ok(_) => {
|
||||
preset = name.trim().to_string();
|
||||
d.fields.insert("presetName".into(), json!(""));
|
||||
d.fields.insert("status".into(), json!(tr("Preset saved.")));
|
||||
}
|
||||
Err(e) => {
|
||||
d.fields.insert("status".into(), json!(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Layouts.
|
||||
section(ui, "Layouts");
|
||||
if layout_names.len() > 1 {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for name in &layout_names {
|
||||
let mut on = job.layouts.is_empty() || job.layouts.contains(name);
|
||||
if ui.checkbox(&mut on, name.as_str()).changed() {
|
||||
let mut chosen: Vec<String> = if job.layouts.is_empty() { layout_names.clone() } else { job.layouts.clone() };
|
||||
if on {
|
||||
chosen.push(name.clone());
|
||||
} else {
|
||||
chosen.retain(|x| x != name);
|
||||
}
|
||||
// In document order; every layout chosen is "all" (it follows new layouts too).
|
||||
let ordered: Vec<String> = layout_names.iter().filter(|n| chosen.contains(n)).cloned().collect();
|
||||
job.layouts = if ordered.len() == layout_names.len() {
|
||||
vec![]
|
||||
} else if ordered.is_empty() {
|
||||
vec![String::new()]
|
||||
} else {
|
||||
ordered
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if ui.radio(!job.combine, crate::rtl::widget(ui, tr("A file per layout"))).clicked() {
|
||||
job.combine = false;
|
||||
}
|
||||
if ui.radio(job.combine, crate::rtl::widget(ui, tr("All layouts in one PDF"))).clicked() {
|
||||
job.combine = true;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
crate::rtl::label(ui, egui::RichText::new(tr("The document has one layout: all its pages are exported.")).color(t.text_dim));
|
||||
}
|
||||
|
||||
// ---- Versions.
|
||||
section(ui, "Versions");
|
||||
let mut remove = None;
|
||||
let mut duplicate = None;
|
||||
let mut expanded: Vec<u64> = d.fields.get("expanded").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
|
||||
for (i, v) in job.versions.iter_mut().enumerate() {
|
||||
ui.push_id(("export_version", i), |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut v.name).desired_width(170.0).hint_text(tr("Folder name")));
|
||||
craft_ui::ComboBox::from_id_salt("format").selected_text(v.format.ext().to_uppercase().replace("JPG", "JPEG")).width(70.0).show_ui(
|
||||
ui,
|
||||
|ui| {
|
||||
for (f, label) in [(ExportFormat::Pdf, "PDF"), (ExportFormat::Png, "PNG"), (ExportFormat::Jpeg, "JPEG")] {
|
||||
if ui.selectable_label(v.format == f, label).clicked() {
|
||||
v.format = f;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
match v.format {
|
||||
ExportFormat::Pdf => {
|
||||
let kind = if expanded.contains(&(i as u64)) { "custom" } else { pdf_kind(v) };
|
||||
let label = |k: &str| match k {
|
||||
"print" => tr("Print: marks + bleed"),
|
||||
"bleed" => tr("Bleed, no marks"),
|
||||
"screen" => tr("Screen"),
|
||||
_ => tr("Custom"),
|
||||
};
|
||||
craft_ui::ComboBox::from_id_salt("pdf_kind").selected_text(label(kind)).width(150.0).show_ui(ui, |ui| {
|
||||
for k in ["print", "bleed", "screen", "custom"] {
|
||||
if ui.selectable_label(kind == k, label(k)).clicked() {
|
||||
expanded.retain(|x| *x != i as u64);
|
||||
if k == "custom" {
|
||||
expanded.push(i as u64);
|
||||
} else {
|
||||
set_pdf_kind(v, k);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
ExportFormat::Png | ExportFormat::Jpeg => {
|
||||
ui.add(egui::DragValue::new(&mut v.ppi).range(36.0..=1200.0).suffix(" ppi").speed(1.0));
|
||||
if v.format == ExportFormat::Jpeg {
|
||||
let mut q = u32::from(v.quality);
|
||||
if ui.add(egui::DragValue::new(&mut q).range(1..=100).prefix(tr("Quality ")).speed(1.0)).changed() {
|
||||
v.quality = u8::try_from(q.clamp(1, 100)).unwrap_or(85);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ui.small_button(tr("Duplicate")).on_hover_text(tr("Duplicate version")).clicked() {
|
||||
duplicate = Some(i);
|
||||
}
|
||||
if crate::icons::button(ui, "trash", 18.0, false, &tr("Remove version")).clicked() {
|
||||
remove = Some(i);
|
||||
}
|
||||
});
|
||||
if v.format == ExportFormat::Pdf && (expanded.contains(&(i as u64)) || pdf_kind(v) == "custom") {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.add_space(12.0);
|
||||
ui.checkbox(&mut v.bleed, crate::rtl::widget(ui, tr("Bleed")));
|
||||
ui.checkbox(&mut v.marks.crop, crate::rtl::widget(ui, tr("Crop Marks")));
|
||||
ui.checkbox(&mut v.marks.bleed, crate::rtl::widget(ui, tr("Bleed Marks")));
|
||||
ui.checkbox(&mut v.marks.page_info, crate::rtl::widget(ui, tr("Page Information")));
|
||||
ui.checkbox(&mut v.compress_images, crate::rtl::widget(ui, tr("Compress Images")));
|
||||
ui.checkbox(&mut v.spreads, crate::rtl::widget(ui, tr("Spreads")));
|
||||
craft_ui::ComboBox::from_id_salt("standard")
|
||||
.selected_text(match v.standard.as_str() {
|
||||
"x4" => "PDF/X-4",
|
||||
"a2b" => "PDF/A-2b",
|
||||
_ => "No standard",
|
||||
})
|
||||
.width(110.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for (s, l) in [("none", "No standard"), ("x4", "PDF/X-4"), ("a2b", "PDF/A-2b")] {
|
||||
if ui.selectable_label(v.standard == s, l).clicked() {
|
||||
v.standard = s.into();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(i) = duplicate
|
||||
&& let Some(v) = job.versions.get(i).cloned()
|
||||
{
|
||||
let mut copy = v;
|
||||
copy.name = format!("{} copy", copy.name);
|
||||
job.versions.insert(i.saturating_add(1), copy);
|
||||
expanded.clear();
|
||||
}
|
||||
if let Some(i) = remove
|
||||
&& i < job.versions.len()
|
||||
{
|
||||
job.versions.remove(i);
|
||||
expanded.clear();
|
||||
}
|
||||
d.fields.insert("expanded".into(), json!(expanded));
|
||||
ui.horizontal(|ui| {
|
||||
let add = ui.button(crate::rtl::widget(ui, tr("+ Add Version")));
|
||||
egui::Popup::menu(&add).show(|ui| {
|
||||
for v in version_templates() {
|
||||
if ui.button(&v.name).clicked() {
|
||||
job.versions.push(v);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.checkbox(&mut job.folders, crate::rtl::widget(ui, tr("Put each version in its own folder")));
|
||||
});
|
||||
|
||||
// ---- File names.
|
||||
section(ui, "File Names");
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut job.file_name).desired_width(240.0).id_salt("export_batch_name"));
|
||||
for token in ["{document}", "{layout}", "{version}", "{date}"] {
|
||||
if ui.small_button(token).on_hover_text(tr("Add to the name")).clicked() {
|
||||
job.file_name.push_str(if job.file_name.is_empty() || job.file_name.ends_with(' ') { "" } else { " " });
|
||||
job.file_name.push_str(token);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Preview: the files it will write.
|
||||
if job != before {
|
||||
preset = all.iter().find(|p| p.job == job).map(|p| p.name.clone()).unwrap_or_default();
|
||||
}
|
||||
let key = serde_json::to_string(&job).unwrap_or_default();
|
||||
if d.s("previewFor") != key {
|
||||
let preview = if job.layouts.iter().any(String::is_empty) {
|
||||
Err(tr("Choose at least one layout."))
|
||||
} else {
|
||||
app.session.execute("file.exportBatch", &json!({"job": job, "dryRun": true})).map_err(|e| e.to_string())
|
||||
};
|
||||
let (files, error) = match preview {
|
||||
Ok(r) => {
|
||||
(r["files"].as_array().map(|a| a.iter().filter_map(|f| f["path"].as_str().map(str::to_string)).collect()).unwrap_or_default(), None)
|
||||
}
|
||||
Err(e) => (Vec::<String>::new(), Some(e)),
|
||||
};
|
||||
d.fields.insert("preview".into(), json!(files));
|
||||
d.fields.insert("previewError".into(), json!(error));
|
||||
d.fields.insert("previewFor".into(), json!(key));
|
||||
}
|
||||
let files: Vec<String> = d.fields.get("preview").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
|
||||
ui.add_space(6.0);
|
||||
match d.fields.get("previewError").and_then(Value::as_str) {
|
||||
Some(e) => {
|
||||
crate::rtl::label(ui, egui::RichText::new(e).color(ui.visuals().error_fg_color));
|
||||
}
|
||||
None => {
|
||||
crate::rtl::label(ui, egui::RichText::new(tr("Files to export: {n}").replace("{n}", &files.len().to_string())).strong());
|
||||
egui::ScrollArea::vertical().id_salt("export_batch_preview").max_height(150.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
for f in files.iter().take(PREVIEW_ROWS) {
|
||||
ui.label(egui::RichText::new(f).monospace().size(11.0).color(t.text_dim));
|
||||
}
|
||||
if files.len() > PREVIEW_ROWS {
|
||||
ui.label(format!("… {}", files.len() - PREVIEW_ROWS));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(status) = d.fields.get("status").and_then(Value::as_str).filter(|s| !s.is_empty()) {
|
||||
crate::rtl::label(ui, egui::RichText::new(status).color(t.text_dim));
|
||||
}
|
||||
d.fields.insert("job".into(), json!(job));
|
||||
d.fields.insert("preset".into(), json!(preset));
|
||||
}
|
||||
|
||||
/// The dialog `d` open again, saying `e`.
|
||||
fn reopen(app: &mut DesignApp, d: &Dialog, e: &str) {
|
||||
let mut d = d.clone();
|
||||
d.fields.insert("status".into(), json!(e));
|
||||
app.ui.dialog = Some(d);
|
||||
}
|
||||
|
||||
/// Export: ask for a folder (the web downloads each file), write every file, remember the job.
|
||||
/// The folder dialog answers on a later frame; the export runs then.
|
||||
pub fn confirm(app: &mut DesignApp, d: &Dialog) -> Result<Value, String> {
|
||||
let job = job_of(d);
|
||||
if job.layouts.iter().any(String::is_empty) {
|
||||
let e = crate::i18n::tr(&app.ui.language, "Choose at least one layout.").to_string();
|
||||
reopen(app, d, &e);
|
||||
return Err(e);
|
||||
}
|
||||
if job.versions.is_empty() {
|
||||
let e = crate::i18n::tr(&app.ui.language, "Add at least one version.").to_string();
|
||||
reopen(app, d, &e);
|
||||
return Err(e);
|
||||
}
|
||||
app.ui.export_batch = Some(job.clone());
|
||||
if app.services.download.is_some() {
|
||||
return export_files(app, d, job, None);
|
||||
}
|
||||
let title = crate::i18n::tr(&app.ui.language, "Export to Folder").to_string();
|
||||
let d = d.clone();
|
||||
app.ask_folder(&title, Bound::Document, move |app, dir| export_files(app, &d, job, Some(dir)))
|
||||
}
|
||||
|
||||
/// Run `job` into folder `dir`, or (no folder: the web) download each file. A failure opens the
|
||||
/// dialog `d` again, saying why.
|
||||
fn export_files(app: &mut DesignApp, d: &Dialog, job: ExportJob, dir: Option<String>) -> Result<Value, String> {
|
||||
let mut params = json!({"job": job});
|
||||
if let Some(dir) = &dir {
|
||||
params["dir"] = json!(dir);
|
||||
}
|
||||
let r = match app.run("file.exportBatch", params) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
reopen(app, d, &e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let files = r["files"].as_array().cloned().unwrap_or_default();
|
||||
if dir.is_none() {
|
||||
// No folders on the web: each file downloads as "Folder - Name.ext".
|
||||
let download = app.services.download.as_mut().ok_or("no download")?;
|
||||
for f in &files {
|
||||
let (Some(path), Some(b64)) = (f["path"].as_str(), f["base64"].as_str()) else { continue };
|
||||
download(&path.replace('/', " - "), &designcraft_engine::cmd::base64_decode(b64))?;
|
||||
}
|
||||
}
|
||||
app.ui.status = crate::i18n::tr(&app.ui.language, "Exported {n} files").replace("{n}", &files.len().to_string());
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn app() -> DesignApp {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"pages": 2, "facingPages": false, "width": 300, "height": 400, "title": "Mag"})).unwrap();
|
||||
app.run("layout.createAlternate", json!({"name": "Tablet H", "width": 400, "height": 300, "fromName": "Print"})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dialog_lists_the_files_and_edits_the_versions() {
|
||||
let mut app = app();
|
||||
app.run("app.exportBatch", json!({})).unwrap();
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.id.as_str(), d.s("preset").as_str()), (ID, "Print + Screen"));
|
||||
let mut h = crate::test_window::open(app, egui::vec2(1400.0, 1000.0));
|
||||
h.run_steps(4);
|
||||
// Print + Screen: two PDFs a version, a PNG a page.
|
||||
assert!(h.query_by_label("Files to export: 8").is_some());
|
||||
assert!(h.query_by_label("PNG/Mag - Tablet H - 2.png").is_some());
|
||||
// Remove the PNG version, duplicate the first: the preset becomes [Custom].
|
||||
h.get_all_by_label("Remove version").last().unwrap().click();
|
||||
h.run_steps(3);
|
||||
h.get_all_by_label("Duplicate").next().unwrap().click();
|
||||
h.run_steps(3);
|
||||
let d = h.state().app.ui.dialog.clone().unwrap();
|
||||
let job = job_of(&d);
|
||||
assert_eq!(
|
||||
job.versions.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(),
|
||||
["PDF Print (marks + bleed)", "PDF Print (marks + bleed) copy", "PDF Screen"]
|
||||
);
|
||||
assert_eq!(d.s("preset"), "");
|
||||
assert!(h.query_by_label("Files to export: 6").is_some());
|
||||
// One layout only.
|
||||
h.get_by_label("Tablet H").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label("Files to export: 3").is_some());
|
||||
assert_eq!(job_of(h.state().app.ui.dialog.as_ref().unwrap()).layouts, ["Print"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_writes_into_the_chosen_folder_and_remembers_the_job() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut app = app();
|
||||
app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(dir.path().to_string_lossy().to_string())));
|
||||
open(&mut app, Some(vec!["Tablet H".into()]));
|
||||
// The folder dialog answers on a later frame.
|
||||
assert_eq!(crate::dialogs::confirm(&mut app).unwrap(), Value::Null);
|
||||
assert!(!dir.path().join("PNG").exists());
|
||||
app.settle_dialogs();
|
||||
assert_eq!(app.ui.status, "Exported 4 files");
|
||||
assert!(dir.path().join("PDF Print (marks + bleed)/Mag - Tablet H.pdf").is_file());
|
||||
assert!(dir.path().join("PNG/Mag - Tablet H - 1.png").is_file());
|
||||
assert_eq!(app.ui.export_batch.as_ref().unwrap().layouts, ["Tablet H"]);
|
||||
// Reopened, it is the same job.
|
||||
open(&mut app, None);
|
||||
assert_eq!(job_of(app.ui.dialog.as_ref().unwrap()).layouts, ["Tablet H"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_folder_writes_nothing_and_the_web_downloads_each_file() {
|
||||
let written = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
let mut app = app();
|
||||
app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(None));
|
||||
open(&mut app, None);
|
||||
assert_eq!(crate::dialogs::confirm(&mut app).unwrap(), Value::Null);
|
||||
app.settle_dialogs();
|
||||
assert!(!app.ui.status.starts_with("Exported"), "{}", app.ui.status);
|
||||
let w = written.clone();
|
||||
app.services.download = Some(Box::new(move |p: &str, _: &[u8]| {
|
||||
w.lock().unwrap().push(p.to_string());
|
||||
Ok(())
|
||||
}));
|
||||
open(&mut app, Some(vec!["Print".into()]));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let names = written.lock().unwrap().clone();
|
||||
assert_eq!(names.len(), 1 + 1 + 2);
|
||||
assert!(names.contains(&"PDF Screen - Mag - Print.pdf".to_string()), "{names:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_are_saved_and_chosen() {
|
||||
let mut app = app();
|
||||
open(&mut app, None);
|
||||
let mut h = crate::test_window::open(app, egui::vec2(1400.0, 1000.0));
|
||||
h.run_steps(3);
|
||||
if let Some(d) = h.state_mut().app.ui.dialog.as_mut() {
|
||||
d.fields.insert("presetName".into(), json!("Client"));
|
||||
}
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Save Preset").click();
|
||||
h.run_steps(3);
|
||||
let app = &h.state().app;
|
||||
assert_eq!(app.session.prefs.export_presets.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Client"]);
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().s("preset"), "Client");
|
||||
assert!(h.query_by_label("Delete Preset").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_with_no_layout_chosen_keeps_the_dialog_open_and_says_why() {
|
||||
let mut app = app();
|
||||
crate::dialogs::open_print(&mut app);
|
||||
if let Some(d) = app.ui.dialog.as_mut() {
|
||||
d.fields.insert("range".into(), json!("layouts"));
|
||||
d.fields.insert("layouts".into(), json!([]));
|
||||
}
|
||||
// Refused before anything is printed.
|
||||
assert!(crate::dialogs::confirm(&mut app).is_err());
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.id.as_str(), d.s("status").as_str()), ("print", "Choose at least one layout."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_a_layout_header_leaves_edit_parents() {
|
||||
let mut app = app();
|
||||
app.ui.dock_tab = "pages".into();
|
||||
app.run("layout.parents.edit", json!({"on": true})).unwrap();
|
||||
let mut h = crate::test_window::open(app, egui::vec2(1400.0, 1000.0));
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Tablet H").click();
|
||||
h.run_steps(4);
|
||||
let app = &h.state().app;
|
||||
assert!(!app.session.active().unwrap().editing_parents);
|
||||
assert_eq!(crate::canvas::current_page(app), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_layout_chosen_keeps_the_dialog_open() {
|
||||
let mut app = app();
|
||||
open(&mut app, None);
|
||||
if let Some(d) = app.ui.dialog.as_mut() {
|
||||
let mut job = job_of(d);
|
||||
job.layouts = vec![String::new()];
|
||||
d.fields.insert("job".into(), json!(job));
|
||||
}
|
||||
assert!(crate::dialogs::confirm(&mut app).is_err());
|
||||
assert!(app.ui.dialog.as_ref().is_some_and(|d| d.id == ID));
|
||||
}
|
||||
}
|
||||
@@ -1762,6 +1762,25 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("New Note", "ملاحظة جديدة"),
|
||||
("New Object Style…", "نمط كائن جديد…"),
|
||||
("New Parent…", "صفحة أصلية جديدة…"),
|
||||
("Export Layouts and Versions…", "تصدير التخطيطات والإصدارات…"),
|
||||
("Export Layouts and Versions", "تصدير التخطيطات والإصدارات"),
|
||||
("Export Layout…", "تصدير التخطيط…"),
|
||||
("Versions", "الإصدارات"),
|
||||
("A file per layout", "ملف لكل تخطيط"),
|
||||
("All layouts in one PDF", "كل التخطيطات في PDF واحد"),
|
||||
("Put each version in its own folder", "ضع كل إصدار في مجلد خاص به"),
|
||||
("+ Add Version", "+ إضافة إصدار"),
|
||||
("Files to export: {n}", "ملفات للتصدير: {n}"),
|
||||
("Print each layout separately", "طباعة كل تخطيط على حدة"),
|
||||
("Parent Options…", "خيارات الصفحة الأصلية…"),
|
||||
("Parent Options", "خيارات الصفحة الأصلية"),
|
||||
("Duplicate Parent Spread", "تكرار الصفحة الأصلية"),
|
||||
("Delete Parent", "حذف الصفحة الأصلية"),
|
||||
("Alternate Layout Options…", "خيارات التخطيط البديل…"),
|
||||
("Duplicate Alternate Layout", "تكرار التخطيط البديل"),
|
||||
("Delete Alternate Layout", "حذف التخطيط البديل"),
|
||||
("Layout Options", "خيارات التخطيط"),
|
||||
("Based on Parent:", "مستندة إلى الصفحة الأصلية:"),
|
||||
("New Tag", "وسم جديد"),
|
||||
("Paste in Place", "لصق في الموضع نفسه"),
|
||||
("Place Loaded Graphic", "وضع الرسم المحمل"),
|
||||
|
||||
@@ -930,6 +930,25 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("New Note", "Nová poznámka"),
|
||||
("New Object Style…", "Nový styl objektu…"),
|
||||
("New Parent…", "Nový vzor…"),
|
||||
("Export Layouts and Versions…", "Exportovat rozvržení a verze…"),
|
||||
("Export Layouts and Versions", "Exportovat rozvržení a verze"),
|
||||
("Export Layout…", "Exportovat rozvržení…"),
|
||||
("Versions", "Verze"),
|
||||
("A file per layout", "Soubor pro každé rozvržení"),
|
||||
("All layouts in one PDF", "Všechna rozvržení v jednom PDF"),
|
||||
("Put each version in its own folder", "Každou verzi do vlastní složky"),
|
||||
("+ Add Version", "+ Přidat verzi"),
|
||||
("Files to export: {n}", "Soubory k exportu: {n}"),
|
||||
("Print each layout separately", "Tisknout každé rozvržení zvlášť"),
|
||||
("Parent Options…", "Volby vzoru…"),
|
||||
("Parent Options", "Volby vzoru"),
|
||||
("Duplicate Parent Spread", "Duplikovat dvojstranu vzoru"),
|
||||
("Delete Parent", "Odstranit vzor"),
|
||||
("Alternate Layout Options…", "Volby alternativního rozvržení…"),
|
||||
("Duplicate Alternate Layout", "Duplikovat alternativní rozvržení"),
|
||||
("Delete Alternate Layout", "Odstranit alternativní rozvržení"),
|
||||
("Layout Options", "Volby rozvržení"),
|
||||
("Based on Parent:", "Založeno na vzoru:"),
|
||||
("New Tag", "Nový tag"),
|
||||
("Opacity", "Krytí"),
|
||||
("Paste in Place", "Vložit na místo"),
|
||||
|
||||
@@ -899,6 +899,25 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("New Note", "Neue Notiz"),
|
||||
("New Object Style…", "Neues Objektformat …"),
|
||||
("New Parent…", "Neue Vorlagenseite …"),
|
||||
("Export Layouts and Versions…", "Layouts und Versionen exportieren …"),
|
||||
("Export Layouts and Versions", "Layouts und Versionen exportieren"),
|
||||
("Export Layout…", "Layout exportieren …"),
|
||||
("Versions", "Versionen"),
|
||||
("A file per layout", "Eine Datei pro Layout"),
|
||||
("All layouts in one PDF", "Alle Layouts in einem PDF"),
|
||||
("Put each version in its own folder", "Jede Version in einem eigenen Ordner"),
|
||||
("+ Add Version", "+ Version hinzufügen"),
|
||||
("Files to export: {n}", "Zu exportierende Dateien: {n}"),
|
||||
("Print each layout separately", "Jedes Layout einzeln drucken"),
|
||||
("Parent Options…", "Vorlagenoptionen …"),
|
||||
("Parent Options", "Vorlagenoptionen"),
|
||||
("Duplicate Parent Spread", "Vorlagendruckbogen duplizieren"),
|
||||
("Delete Parent", "Vorlagenseite löschen"),
|
||||
("Alternate Layout Options…", "Optionen für alternatives Layout …"),
|
||||
("Duplicate Alternate Layout", "Alternatives Layout duplizieren"),
|
||||
("Delete Alternate Layout", "Alternatives Layout löschen"),
|
||||
("Layout Options", "Layoutoptionen"),
|
||||
("Based on Parent:", "Basierend auf Vorlage:"),
|
||||
("New Tag", "Neues Tag"),
|
||||
("Opacity", "Deckkraft"),
|
||||
("Paste in Place", "An Originalposition einfügen"),
|
||||
|
||||
@@ -215,6 +215,23 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("New Layer", "Nouveau calque"),
|
||||
("New Page Reference", "Nouvelle référence de page"),
|
||||
("New Parent", "Nouveau gabarit"),
|
||||
("Export Layouts and Versions…", "Exporter les mises en page et versions…"),
|
||||
("Export Layouts and Versions", "Exporter les mises en page et versions"),
|
||||
("Export Layout…", "Exporter la mise en page…"),
|
||||
("Versions", "Versions"),
|
||||
("A file per layout", "Un fichier par mise en page"),
|
||||
("All layouts in one PDF", "Toutes les mises en page dans un PDF"),
|
||||
("Put each version in its own folder", "Chaque version dans son propre dossier"),
|
||||
("+ Add Version", "+ Ajouter une version"),
|
||||
("Files to export: {n}", "Fichiers à exporter : {n}"),
|
||||
("Print each layout separately", "Imprimer chaque mise en page séparément"),
|
||||
("Parent Options", "Options du gabarit"),
|
||||
("Duplicate Parent Spread", "Dupliquer la planche de gabarit"),
|
||||
("Alternate Layout Options…", "Options de la mise en page alternative…"),
|
||||
("Duplicate Alternate Layout", "Dupliquer la mise en page alternative"),
|
||||
("Delete Alternate Layout", "Supprimer la mise en page alternative"),
|
||||
("Layout Options", "Options de mise en page"),
|
||||
("Based on Parent:", "D'après le gabarit :"),
|
||||
("New Stroke Style", "Nouveau style de contour"),
|
||||
("New Table Style", "Nouveau style de tableau"),
|
||||
("Next Page Number", "Numéro de la page suivante"),
|
||||
|
||||
@@ -1006,6 +1006,25 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("New Page Reference…", "Új oldalhivatkozás…"),
|
||||
("New Paragraph Style…", "Új bekezdésstílus…"),
|
||||
("New Parent", "Új mesteroldal"),
|
||||
("Export Layouts and Versions…", "Elrendezések és változatok exportálása…"),
|
||||
("Export Layouts and Versions", "Elrendezések és változatok exportálása"),
|
||||
("Export Layout…", "Elrendezés exportálása…"),
|
||||
("Versions", "Változatok"),
|
||||
("A file per layout", "Elrendezésenként egy fájl"),
|
||||
("All layouts in one PDF", "Minden elrendezés egy PDF-ben"),
|
||||
("Put each version in its own folder", "Minden változat külön mappába"),
|
||||
("+ Add Version", "+ Változat hozzáadása"),
|
||||
("Files to export: {n}", "Exportálandó fájlok: {n}"),
|
||||
("Print each layout separately", "Elrendezések nyomtatása külön-külön"),
|
||||
("Parent Options…", "Mesteroldal beállításai…"),
|
||||
("Parent Options", "Mesteroldal beállításai"),
|
||||
("Duplicate Parent Spread", "Mesteroldalpár másolása"),
|
||||
("Delete Parent", "Mesteroldal törlése"),
|
||||
("Alternate Layout Options…", "Alternatív elrendezés beállításai…"),
|
||||
("Duplicate Alternate Layout", "Alternatív elrendezés másolása"),
|
||||
("Delete Alternate Layout", "Alternatív elrendezés törlése"),
|
||||
("Layout Options", "Elrendezés beállításai"),
|
||||
("Based on Parent:", "Alapul szolgáló mesteroldal:"),
|
||||
("New Parent…", "Új mesteroldal…"),
|
||||
("New Stroke Style", "Új körvonalstílus"),
|
||||
("New Stroke Style…", "Új körvonalstílus…"),
|
||||
|
||||
@@ -1063,6 +1063,26 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("New Note", "Nuova nota"),
|
||||
("New Object Style…", "Nuovo stile oggetto…"),
|
||||
("New Parent…", "Nuova pagina mastro…"),
|
||||
("Export Layouts and Versions…", "Esporta layout e versioni…"),
|
||||
("Export Layouts and Versions", "Esporta layout e versioni"),
|
||||
("Export Layout…", "Esporta layout…"),
|
||||
("Versions", "Versioni"),
|
||||
("A file per layout", "Un file per layout"),
|
||||
("All layouts in one PDF", "Tutti i layout in un PDF"),
|
||||
("Put each version in its own folder", "Ogni versione nella propria cartella"),
|
||||
("+ Add Version", "+ Aggiungi versione"),
|
||||
("Files to export: {n}", "File da esportare: {n}"),
|
||||
("Print each layout separately", "Stampa ogni layout separatamente"),
|
||||
("Parent Options…", "Opzioni pagina mastro…"),
|
||||
("Parent Options", "Opzioni pagina mastro"),
|
||||
("Duplicate Parent Spread", "Duplica pagine affiancate mastro"),
|
||||
("Delete Parent", "Elimina pagina mastro"),
|
||||
("New Parent", "Nuova pagina mastro"),
|
||||
("Alternate Layout Options…", "Opzioni layout alternativo…"),
|
||||
("Duplicate Alternate Layout", "Duplica layout alternativo"),
|
||||
("Delete Alternate Layout", "Elimina layout alternativo"),
|
||||
("Layout Options", "Opzioni layout"),
|
||||
("Based on Parent:", "Basata su mastro:"),
|
||||
("New Tag", "Nuovo tag"),
|
||||
("Opacity", "Opacità"),
|
||||
("Paste in Place", "Incolla nella stessa posizione"),
|
||||
|
||||
@@ -1085,6 +1085,25 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("New Note", "新規注釈"),
|
||||
("New Object Style…", "新規オブジェクトスタイル…"),
|
||||
("New Parent…", "新規親ページ…"),
|
||||
("Export Layouts and Versions…", "レイアウトとバージョンを書き出し…"),
|
||||
("Export Layouts and Versions", "レイアウトとバージョンを書き出し"),
|
||||
("Export Layout…", "レイアウトを書き出し…"),
|
||||
("Versions", "バージョン"),
|
||||
("A file per layout", "レイアウトごとに 1 ファイル"),
|
||||
("All layouts in one PDF", "すべてのレイアウトを 1 つの PDF に"),
|
||||
("Put each version in its own folder", "バージョンごとにフォルダーを分ける"),
|
||||
("+ Add Version", "+ バージョンを追加"),
|
||||
("Files to export: {n}", "書き出すファイル: {n}"),
|
||||
("Print each layout separately", "レイアウトごとに別々に印刷"),
|
||||
("Parent Options…", "親ページオプション…"),
|
||||
("Parent Options", "親ページオプション"),
|
||||
("Duplicate Parent Spread", "親スプレッドを複製"),
|
||||
("Delete Parent", "親ページを削除"),
|
||||
("Alternate Layout Options…", "別レイアウトオプション…"),
|
||||
("Duplicate Alternate Layout", "別レイアウトを複製"),
|
||||
("Delete Alternate Layout", "別レイアウトを削除"),
|
||||
("Layout Options", "レイアウトオプション"),
|
||||
("Based on Parent:", "基準親ページ:"),
|
||||
("New Tag", "新規タグ"),
|
||||
("Opacity", "不透明度"),
|
||||
("Paste in Place", "同じ位置にペースト"),
|
||||
|
||||
@@ -986,6 +986,23 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("New Note", "ახალი შენიშვნა"),
|
||||
("New Object Style…", "ობიექტის ახალი სტილი…"),
|
||||
("New Parent…", "ახალი საბაზო გვერდი…"),
|
||||
("Export Layouts and Versions…", "განლაგებებისა და ვერსიების ექსპორტი…"),
|
||||
("Export Layouts and Versions", "განლაგებებისა და ვერსიების ექსპორტი"),
|
||||
("Export Layout…", "განლაგების ექსპორტი…"),
|
||||
("Versions", "ვერსიები"),
|
||||
("A file per layout", "თითო ფაილი თითო განლაგებაზე"),
|
||||
("All layouts in one PDF", "ყველა განლაგება ერთ PDF-ში"),
|
||||
("Put each version in its own folder", "თითოეული ვერსია საკუთარ საქაღალდეში"),
|
||||
("+ Add Version", "+ ვერსიის დამატება"),
|
||||
("Files to export: {n}", "საექსპორტო ფაილები: {n}"),
|
||||
("Print each layout separately", "თითოეული განლაგების ცალკე ბეჭდვა"),
|
||||
("Parent Options", "საბაზო გვერდის პარამეტრები"),
|
||||
("Duplicate Parent Spread", "საბაზო გვერდის გაორმაგება"),
|
||||
("Alternate Layout Options…", "ალტერნატიული განლაგების პარამეტრები…"),
|
||||
("Duplicate Alternate Layout", "ალტერნატიული განლაგების გაორმაგება"),
|
||||
("Delete Alternate Layout", "ალტერნატიული განლაგების წაშლა"),
|
||||
("Layout Options", "განლაგების პარამეტრები"),
|
||||
("Based on Parent:", "საფუძვლად საბაზო გვერდი:"),
|
||||
("New Tag", "ახალი ტეგი"),
|
||||
("Opacity", "გაუმჭვირვალობა"),
|
||||
("Paste in Place", "ადგილზე ჩასმა"),
|
||||
|
||||
@@ -946,6 +946,25 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("New Note", "Nowa notatka"),
|
||||
("New Object Style…", "Nowy styl obiektu…"),
|
||||
("New Parent…", "Nowa strona wzorcowa…"),
|
||||
("Export Layouts and Versions…", "Eksportuj układy i wersje…"),
|
||||
("Export Layouts and Versions", "Eksportuj układy i wersje"),
|
||||
("Export Layout…", "Eksportuj układ…"),
|
||||
("Versions", "Wersje"),
|
||||
("A file per layout", "Plik dla każdego układu"),
|
||||
("All layouts in one PDF", "Wszystkie układy w jednym PDF"),
|
||||
("Put each version in its own folder", "Każda wersja we własnym folderze"),
|
||||
("+ Add Version", "+ Dodaj wersję"),
|
||||
("Files to export: {n}", "Pliki do eksportu: {n}"),
|
||||
("Print each layout separately", "Drukuj każdy układ osobno"),
|
||||
("Parent Options…", "Opcje strony wzorcowej…"),
|
||||
("Parent Options", "Opcje strony wzorcowej"),
|
||||
("Duplicate Parent Spread", "Powiel rozkładówkę wzorcową"),
|
||||
("Delete Parent", "Usuń stronę wzorcową"),
|
||||
("Alternate Layout Options…", "Opcje układu alternatywnego…"),
|
||||
("Duplicate Alternate Layout", "Powiel układ alternatywny"),
|
||||
("Delete Alternate Layout", "Usuń układ alternatywny"),
|
||||
("Layout Options", "Opcje układu"),
|
||||
("Based on Parent:", "Na podstawie strony wzorcowej:"),
|
||||
("New Tag", "Nowy tag"),
|
||||
("Opacity", "Krycie"),
|
||||
("Paste in Place", "Wklej w miejscu"),
|
||||
|
||||
@@ -1179,6 +1179,26 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Insert Pages…", "Inserir páginas…"),
|
||||
("Move Pages…", "Mover páginas…"),
|
||||
("New Parent…", "Nova página principal…"),
|
||||
("Export Layouts and Versions…", "Exportar layouts e versões…"),
|
||||
("Export Layouts and Versions", "Exportar layouts e versões"),
|
||||
("Export Layout…", "Exportar layout…"),
|
||||
("Versions", "Versões"),
|
||||
("A file per layout", "Um arquivo por layout"),
|
||||
("All layouts in one PDF", "Todos os layouts em um PDF"),
|
||||
("Put each version in its own folder", "Cada versão em sua própria pasta"),
|
||||
("+ Add Version", "+ Adicionar versão"),
|
||||
("Files to export: {n}", "Arquivos a exportar: {n}"),
|
||||
("Print each layout separately", "Imprimir cada layout separadamente"),
|
||||
("Parent Options…", "Opções da página principal…"),
|
||||
("Parent Options", "Opções da página principal"),
|
||||
("Duplicate Parent Spread", "Duplicar página principal espelhada"),
|
||||
("Delete Parent", "Excluir página principal"),
|
||||
("New Parent", "Nova página principal"),
|
||||
("Alternate Layout Options…", "Opções do layout alternativo…"),
|
||||
("Duplicate Alternate Layout", "Duplicar layout alternativo"),
|
||||
("Delete Alternate Layout", "Excluir layout alternativo"),
|
||||
("Layout Options", "Opções de layout"),
|
||||
("Based on Parent:", "Baseada na página principal:"),
|
||||
("Override All Parent Page Items", "Substituir todos os itens da página principal"),
|
||||
("Remove All Local Overrides", "Remover todas as substituições locais"),
|
||||
("Detach All Objects from Parent", "Desanexar todos os objetos da página principal"),
|
||||
|
||||
@@ -920,6 +920,25 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("New Note", "Новая заметка"),
|
||||
("New Object Style…", "Новый стиль объекта…"),
|
||||
("New Parent…", "Новый шаблон…"),
|
||||
("Export Layouts and Versions…", "Экспорт макетов и версий…"),
|
||||
("Export Layouts and Versions", "Экспорт макетов и версий"),
|
||||
("Export Layout…", "Экспорт макета…"),
|
||||
("Versions", "Версии"),
|
||||
("A file per layout", "Файл на каждый макет"),
|
||||
("All layouts in one PDF", "Все макеты в одном PDF"),
|
||||
("Put each version in its own folder", "Каждая версия в своей папке"),
|
||||
("+ Add Version", "+ Добавить версию"),
|
||||
("Files to export: {n}", "Файлов для экспорта: {n}"),
|
||||
("Print each layout separately", "Печатать каждый макет отдельно"),
|
||||
("Parent Options…", "Параметры шаблона…"),
|
||||
("Parent Options", "Параметры шаблона"),
|
||||
("Duplicate Parent Spread", "Дублировать разворот шаблона"),
|
||||
("Delete Parent", "Удалить шаблон"),
|
||||
("Alternate Layout Options…", "Параметры альтернативного макета…"),
|
||||
("Duplicate Alternate Layout", "Дублировать альтернативный макет"),
|
||||
("Delete Alternate Layout", "Удалить альтернативный макет"),
|
||||
("Layout Options", "Параметры макета"),
|
||||
("Based on Parent:", "На основе шаблона:"),
|
||||
("New Tag", "Новый тег"),
|
||||
("Opacity", "Непрозрачность"),
|
||||
("Paste in Place", "Вставить на то же место"),
|
||||
|
||||
@@ -1715,6 +1715,23 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("New Hyperlink Destination", "Nytt hyperlänkmål"),
|
||||
("New Page Reference", "Ny sidreferens"),
|
||||
("New Parent", "Ny mallsida"),
|
||||
("Export Layouts and Versions…", "Exportera layouter och versioner…"),
|
||||
("Export Layouts and Versions", "Exportera layouter och versioner"),
|
||||
("Export Layout…", "Exportera layout…"),
|
||||
("Versions", "Versioner"),
|
||||
("A file per layout", "En fil per layout"),
|
||||
("All layouts in one PDF", "Alla layouter i en PDF"),
|
||||
("Put each version in its own folder", "Lägg varje version i en egen mapp"),
|
||||
("+ Add Version", "+ Lägg till version"),
|
||||
("Files to export: {n}", "Filer att exportera: {n}"),
|
||||
("Print each layout separately", "Skriv ut varje layout separat"),
|
||||
("Parent Options", "Alternativ för mallsida"),
|
||||
("Duplicate Parent Spread", "Duplicera mallsideuppslag"),
|
||||
("Alternate Layout Options…", "Alternativ för alternativ layout…"),
|
||||
("Duplicate Alternate Layout", "Duplicera alternativ layout"),
|
||||
("Delete Alternate Layout", "Radera alternativ layout"),
|
||||
("Layout Options", "Layoutalternativ"),
|
||||
("Based on Parent:", "Baserat på mallsida:"),
|
||||
("New Stroke Style", "Ny konturstil"),
|
||||
("Next", "Nästa"),
|
||||
("Next Page Number", "Nästa sidnummer"),
|
||||
|
||||
@@ -1066,6 +1066,26 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("New Note", "Нова примітка"),
|
||||
("New Object Style…", "Новий стиль об’єкта…"),
|
||||
("New Parent…", "Новий шаблон…"),
|
||||
("Export Layouts and Versions…", "Експорт макетів і версій…"),
|
||||
("Export Layouts and Versions", "Експорт макетів і версій"),
|
||||
("Export Layout…", "Експорт макета…"),
|
||||
("Versions", "Версії"),
|
||||
("A file per layout", "Файл на кожен макет"),
|
||||
("All layouts in one PDF", "Усі макети в одному PDF"),
|
||||
("Put each version in its own folder", "Кожна версія у власній теці"),
|
||||
("+ Add Version", "+ Додати версію"),
|
||||
("Files to export: {n}", "Файлів для експорту: {n}"),
|
||||
("Print each layout separately", "Друкувати кожен макет окремо"),
|
||||
("Parent Options…", "Параметри шаблону…"),
|
||||
("Parent Options", "Параметри шаблону"),
|
||||
("Duplicate Parent Spread", "Дублювати розворот шаблону"),
|
||||
("Delete Parent", "Видалити шаблон"),
|
||||
("New Parent", "Новий шаблон"),
|
||||
("Alternate Layout Options…", "Параметри альтернативного макета…"),
|
||||
("Duplicate Alternate Layout", "Дублювати альтернативний макет"),
|
||||
("Delete Alternate Layout", "Видалити альтернативний макет"),
|
||||
("Layout Options", "Параметри макета"),
|
||||
("Based on Parent:", "На основі шаблону:"),
|
||||
("New Tag", "Новий тег"),
|
||||
("Opacity", "Непрозорість"),
|
||||
("Paste in Place", "Вставити на місце"),
|
||||
|
||||
@@ -24,6 +24,7 @@ pub mod cursors;
|
||||
pub mod dialogs;
|
||||
pub mod dock;
|
||||
pub mod effects_dialog;
|
||||
pub mod export_batch_dialog;
|
||||
pub mod i18n;
|
||||
pub mod icons;
|
||||
pub mod keys;
|
||||
|
||||
@@ -300,6 +300,33 @@ fn run_ui_part1(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
|
||||
})
|
||||
}
|
||||
|
||||
/// File › Export Layouts and Versions…: the dialog, on `layouts` when given.
|
||||
fn open_export_batch(app: &mut DesignApp, p: &Value) -> Result<Value, String> {
|
||||
if app.session.active().is_none() {
|
||||
return Err("no document open".into());
|
||||
}
|
||||
let layouts = p.get("layouts").and_then(|v| serde_json::from_value::<Vec<String>>(v.clone()).ok());
|
||||
crate::export_batch_dialog::open(app, layouts);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Layout › Parent Options… (the parent selected in the Pages panel or shown, else the first) and
|
||||
/// Layout › Alternate Layout Options… (the layout of the page in view).
|
||||
fn open_layout_options(app: &mut DesignApp, id: &str) -> Result<Value, String> {
|
||||
let Some(st) = app.session.active() else { return Err("no document open".into()) };
|
||||
let doc = st.doc.clone();
|
||||
if id == "app.parentOptions" {
|
||||
let target = crate::panels::pages::target_parent(app, &doc).or_else(|| doc.parents.first().map(|p| p.id));
|
||||
let Some(target) = target else { return Err("the document has no parents".into()) };
|
||||
crate::panels::pages::open_parent_options(app, &doc, target);
|
||||
} else {
|
||||
let layouts = doc.layouts();
|
||||
let k = crate::panels::pages::layout_index(&layouts, crate::canvas::current_page(app).unwrap_or(0));
|
||||
crate::panels::pages::open_layout_options(app, &doc, &layouts, k);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// One slice of the UI commands: `None` if `id` isn't one of them.
|
||||
fn run_ui_part2(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
||||
Some(match id {
|
||||
@@ -475,6 +502,8 @@ fn run_ui_part2(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
|
||||
};
|
||||
return Some(r);
|
||||
}
|
||||
"app.exportBatch" => return Some(open_export_batch(app, p)),
|
||||
"app.parentOptions" | "app.alternateLayoutOptions" => return Some(open_layout_options(app, id)),
|
||||
"app.tabsPanel" => {
|
||||
toggle_tabs_panel(app);
|
||||
Ok(Value::Null)
|
||||
|
||||
@@ -19,6 +19,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
||||
"ui:app.placeWithOptions",
|
||||
"-",
|
||||
"ui:app.exportPdf",
|
||||
"ui:app.exportBatch",
|
||||
"ui:app.packageDialog",
|
||||
"ui:app.printBooklet",
|
||||
"ui:app.exportPng",
|
||||
@@ -101,10 +102,12 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
||||
"-",
|
||||
"cmd:layout.pages.applyParent",
|
||||
"cmd:layout.parents.new",
|
||||
"ui:app.parentOptions",
|
||||
"<",
|
||||
"cmd:layout.marginsAndColumns",
|
||||
"cmd:layout.createGuides",
|
||||
"cmd:layout.createAlternate",
|
||||
"ui:app.alternateLayoutOptions",
|
||||
"-",
|
||||
"ui:view.firstPage",
|
||||
"ui:view.previousPage",
|
||||
|
||||
@@ -84,6 +84,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
("app.placeWithOptions", "Place with Import Options…", Some("Cmd+Shift+D"), "{path?} — Word/RTF style mapping before placing"),
|
||||
("app.exportText", "Export Text…", None, "{path?} — the story being edited, as Text Only (.txt) or Rich Text Format (.rtf)"),
|
||||
("app.exportPdf", "Export PDF…", None, "{path?, …file.exportPdf options} — asks for a path when none is given"),
|
||||
(
|
||||
"app.exportBatch",
|
||||
"Export Layouts and Versions…",
|
||||
None,
|
||||
"{layouts?: [names]} — the Export Layouts and Versions dialog (several formats and layouts at once, presets)",
|
||||
),
|
||||
("app.palette", "Quick Apply…", Some("Cmd+Return"), "{} — search styles and commands"),
|
||||
("app.preferences", "Preferences…", Some("Cmd+K"), "{}"),
|
||||
("window.uiScale", "UI Scaling", None, "{scale: 0.5–3 (1 = 100%)} — the size of the whole interface"),
|
||||
@@ -169,6 +175,13 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
("app.deleteAllGuides", "Delete All Guides on Spread", None, "{} — the spread in view"),
|
||||
("app.deletePage", "Delete Page", None, "{} — deletes the page in view"),
|
||||
("app.duplicateSpread", "Duplicate Spread", None, "{} — duplicates the spread in view (pages and items)"),
|
||||
(
|
||||
"app.parentOptions",
|
||||
"Parent Options…",
|
||||
None,
|
||||
"{} — the Parent Options dialog for the parent selected in the Pages panel (or shown), else the first",
|
||||
),
|
||||
("app.alternateLayoutOptions", "Alternate Layout Options…", None, "{} — the Alternate Layout Options dialog for the layout of the page in view"),
|
||||
("app.tablePanel", "Table Panel", Some("Shift+F9"), "{}"),
|
||||
("app.tabsPanel", "Tabs", Some("Cmd+Shift+T"), "{} — show or hide the Tabs panel (it opens above the selected text frame)"),
|
||||
("app.characterPanel", "Character", Some("Cmd+T"), "{} — the Character panel"),
|
||||
|
||||
+452
-144
@@ -203,6 +203,11 @@ const BADGE: Color32 = Color32::from_rgb(0x2f, 0x73, 0xe0);
|
||||
const THUMB_H: f32 = 37.0;
|
||||
/// Parent rows.
|
||||
const ROW_H: f32 = 29.0;
|
||||
/// The New Parent row under the parents.
|
||||
const NEW_PARENT_H: f32 = 20.0;
|
||||
/// An alternate layout's column: its header's height and the narrowest column.
|
||||
const LAYOUT_HEADER_H: f32 = 34.0;
|
||||
const LAYOUT_COL_MIN: f32 = 96.0;
|
||||
|
||||
/// A spread's label under its thumbnails, as InDesign's: one name for a single page, "2–3" for
|
||||
/// pages numbered on in one section (an en dash), and the names apart ("2, 1", "8, A") when a section starts
|
||||
@@ -299,6 +304,8 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Outside Edit Parents, the parent clicked in the list (the panel menu acts on it).
|
||||
let selected_parent = selected_parent(app).filter(|id| !editing_parents && doc.parent_index(*id).is_some());
|
||||
let mut rows = vec![ParentRow::none(aspect)];
|
||||
rows.extend(doc.parents.iter().enumerate().map(|(i, p)| ParentRow::new(i, p, aspect)));
|
||||
// As in InDesign: parents and pages each scroll on their own (parents on top unless Panel
|
||||
@@ -329,7 +336,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
x += w;
|
||||
}
|
||||
lines * (icon_h + 22.0)
|
||||
};
|
||||
} + NEW_PARENT_H;
|
||||
let space = (total - FOOTER_H - DIVIDER_H).max(2.0 * ROW_H);
|
||||
let parents_max = (space - 2.0 * ROW_H).max(ROW_H);
|
||||
// A row taller than the room left (large icons in a short panel) gets that room.
|
||||
@@ -359,7 +366,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
} else {
|
||||
ui.allocate_exact_size(vec2(tile_w(row.unit_w), icon_h + 22.0), Sense::click_and_drag())
|
||||
};
|
||||
let active = row.parent.is_some_and(|(i, _)| cur_parent == Some(i));
|
||||
let active = row.parent.is_some_and(|(i, id)| cur_parent == Some(i) || selected_parent == Some(id));
|
||||
if active {
|
||||
ui.painter().rect_filled(r, 0.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
@@ -389,6 +396,9 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, label));
|
||||
let resp = if opts.parents_vertical { resp } else { resp.on_hover_text(label) };
|
||||
resp.dnd_set_drag_payload(PageDrag::Parent(row.parent.map(|(_, id)| id)));
|
||||
if resp.clicked() {
|
||||
select_parent(app, row.parent.map(|(_, id)| id));
|
||||
}
|
||||
if resp.double_clicked()
|
||||
&& let Some((_, id)) = row.parent
|
||||
{
|
||||
@@ -398,15 +408,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
drag_ghost(ui, label, &t);
|
||||
}
|
||||
if let Some((_, id)) = row.parent {
|
||||
resp.context_menu(|ui| {
|
||||
let label = crate::i18n::tr(&app.ui.language, "Delete Parent");
|
||||
if ui.button(crate::rtl::widget(ui, label)).clicked() {
|
||||
if app.run("layout.parents.delete", json!({"id": id.0})).is_ok() {
|
||||
app.views.clear();
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
resp.context_menu(|ui| parent_menu(app, ui, &doc, Some(id), false));
|
||||
}
|
||||
};
|
||||
if opts.parents_vertical {
|
||||
@@ -421,6 +423,25 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
});
|
||||
}
|
||||
// New Parent in one click: the next free prefix (B, C…), named Parent.
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), NEW_PARENT_H), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
let label = crate::i18n::tr(&app.ui.language, "New Parent");
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(3.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
format!("+ {label}"),
|
||||
egui::FontId::proportional(11.0),
|
||||
t.text_dim,
|
||||
);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
|
||||
if resp.clicked()
|
||||
&& let Ok(r) = app.run("layout.parents.new", json!({}))
|
||||
{
|
||||
select_parent(app, r.get("id").and_then(serde_json::Value::as_u64).map(SpreadId));
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -433,146 +454,182 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let th = opts.page_size.page_h();
|
||||
// Extra Small has no room for a thumbnail; Show Thumbnails off draws blank pages.
|
||||
let thumbnails = opts.page_thumbnails && opts.page_size != IconSize::ExtraSmall;
|
||||
let layouts = doc.layouts();
|
||||
let columns = layouts.len() > 1;
|
||||
let mut pages_part = |app: &mut DesignApp, ui: &mut egui::Ui| {
|
||||
let center = ui.available_width() / 2.0;
|
||||
egui::ScrollArea::vertical().id_salt("pages_spreads").max_height(pages_h).min_scrolled_height(pages_h).auto_shrink([false, false]).show(
|
||||
ui,
|
||||
|ui| {
|
||||
ui.add_space(4.0);
|
||||
for (si, sp) in doc.spreads.iter().enumerate() {
|
||||
let first = doc.first_page_of_spread(si);
|
||||
// One scale for the whole document (pages of their own size show in proportion): the
|
||||
// document's page size at the panel's size, unless a taller page would pass 1.8 times it.
|
||||
// Each row is as tall as its spread's tallest page.
|
||||
let scale = (th / doc.settings.page_height.max(1.0) as f32).min(1.8 * th / tallest as f32);
|
||||
let row_h = sp.pages.iter().map(|p| p.height as f32 * scale).fold(0.0, f32::max).max(th * 0.25);
|
||||
let spine = sp.spine_x() as f32;
|
||||
let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + row_h + 22.0), Sense::hover());
|
||||
let top = row.min.y + 8.0;
|
||||
if cur_spread == Some(si) && !editing_parents {
|
||||
let key = (uid, cur);
|
||||
if ui.data(|d| d.get_temp::<(u64, usize)>(followed_id)) != Some(key) {
|
||||
ui.scroll_to_rect(row, None);
|
||||
ui.data_mut(|d| d.insert_temp(followed_id, key));
|
||||
// The document's spreads `spreads`, each a row centred on its spine.
|
||||
let mut draw_spreads = |app: &mut DesignApp, ui: &mut egui::Ui, spreads: std::ops::Range<usize>| {
|
||||
let center = ui.available_width() / 2.0;
|
||||
for (si, sp) in doc.spreads.iter().enumerate().take(spreads.end).skip(spreads.start) {
|
||||
let first = doc.first_page_of_spread(si);
|
||||
// One scale for the whole document (pages of their own size show in proportion): the
|
||||
// document's page size at the panel's size, unless a taller page would pass 1.8 times it.
|
||||
// Each row is as tall as its spread's tallest page.
|
||||
let scale = (th / doc.settings.page_height.max(1.0) as f32).min(1.8 * th / tallest as f32);
|
||||
let row_h = sp.pages.iter().map(|p| p.height as f32 * scale).fold(0.0, f32::max).max(th * 0.25);
|
||||
let spine = sp.spine_x() as f32;
|
||||
let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + row_h + 22.0), Sense::hover());
|
||||
let top = row.min.y + 8.0;
|
||||
if cur_spread == Some(si) && !editing_parents {
|
||||
let key = (uid, cur);
|
||||
if ui.data(|d| d.get_temp::<(u64, usize)>(followed_id)) != Some(key) {
|
||||
ui.scroll_to_rect(row, None);
|
||||
ui.data_mut(|d| d.insert_temp(followed_id, key));
|
||||
}
|
||||
}
|
||||
for (pi, p) in sp.pages.iter().enumerate() {
|
||||
let abs = first + pi;
|
||||
let x0 = row.min.x + center + (p.x as f32 - spine) * scale;
|
||||
let pr = egui::Rect::from_min_size(egui::pos2(x0, top), vec2(p.width as f32 * scale, p.height as f32 * scale));
|
||||
let resp = ui.interact(pr, ui.scope_id().with(("page", abs)), Sense::click_and_drag());
|
||||
let selected = abs == cur && !editing_parents;
|
||||
// Screen readers hear "Page 3".
|
||||
resp.widget_info(|| {
|
||||
egui::WidgetInfo::selected(egui::Role::Button, true, selected, format!("Page {}", app.session.page_label(abs)))
|
||||
});
|
||||
let tint = if selected { SELECTED_TINT } else { Color32::WHITE };
|
||||
match thumbnails.then(|| thumb(app, ui.ctx(), ThumbOf::Page(abs), th)).flatten() {
|
||||
Some(tex) => {
|
||||
ui.painter().image(tex.id(), pr, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), tint);
|
||||
}
|
||||
None => {
|
||||
ui.painter().rect_filled(pr, 0.0, tint);
|
||||
}
|
||||
}
|
||||
for (pi, p) in sp.pages.iter().enumerate() {
|
||||
let abs = first + pi;
|
||||
let x0 = row.min.x + center + (p.x as f32 - spine) * scale;
|
||||
let pr = egui::Rect::from_min_size(egui::pos2(x0, top), vec2(p.width as f32 * scale, p.height as f32 * scale));
|
||||
let resp = ui.interact(pr, ui.scope_id().with(("page", abs)), Sense::click_and_drag());
|
||||
let selected = abs == cur && !editing_parents;
|
||||
// Screen readers hear "Page 3".
|
||||
resp.widget_info(|| {
|
||||
egui::WidgetInfo::selected(egui::Role::Button, true, selected, format!("Page {}", app.session.page_label(abs)))
|
||||
});
|
||||
let tint = if selected { SELECTED_TINT } else { Color32::WHITE };
|
||||
match thumbnails.then(|| thumb(app, ui.ctx(), ThumbOf::Page(abs), th)).flatten() {
|
||||
Some(tex) => {
|
||||
ui.painter().image(tex.id(), pr, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), tint);
|
||||
}
|
||||
None => {
|
||||
ui.painter().rect_filled(pr, 0.0, tint);
|
||||
}
|
||||
}
|
||||
ui.painter().rect_stroke(pr, 0.0, Stroke::new(0.5, Color32::from_gray(0x4a)), StrokeKind::Outside);
|
||||
if abs == 0 || doc.sections.iter().any(|s| s.start == abs) {
|
||||
let c = egui::pos2(pr.center().x, row.min.y + 4.0);
|
||||
ui.painter().add(egui::Shape::convex_polygon(
|
||||
vec![c + vec2(-4.5, -3.0), c + vec2(4.5, -3.0), c + vec2(0.0, 3.0)],
|
||||
t.icon,
|
||||
Stroke::NONE,
|
||||
));
|
||||
let marker =
|
||||
ui.interact(egui::Rect::from_center_size(c, vec2(12.0, 8.0)), ui.scope_id().with(("section", abs)), Sense::click());
|
||||
let label = format!(
|
||||
"{}: {}",
|
||||
crate::i18n::tr(&app.ui.language, "Numbering & Section Options…").trim_end_matches('…'),
|
||||
app.session.page_label(abs)
|
||||
);
|
||||
marker.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label));
|
||||
// A double click, or the keyboard or a screen reader activating it (a
|
||||
// single pointer click only selects, as on a page).
|
||||
let activated = marker.double_clicked() || marker.clicked() && !marker.clicked_by(egui::PointerButton::Primary);
|
||||
if activated {
|
||||
if editing_parents {
|
||||
let _ = app.run("layout.parents.edit", json!({"on": false}));
|
||||
if let Some(view) = app.view_mut() {
|
||||
view.fitted = false;
|
||||
}
|
||||
}
|
||||
crate::canvas::go_to_page(app, abs);
|
||||
crate::menus::activate(app, "layout.section", &serde_json::Value::Null);
|
||||
}
|
||||
}
|
||||
// Parent prefix in the outer top corner (black on white).
|
||||
if let Some(pid) = p.parent
|
||||
&& let Some(pp) = doc.parents.iter().find(|x| x.id == pid)
|
||||
{
|
||||
let prefix = pp.parent.as_ref().map(|i| i.prefix.clone()).unwrap_or_default();
|
||||
let g = ui.painter().layout_no_wrap(prefix, crate::theme::semibold(9.5), Color32::BLACK);
|
||||
let w = g.size().x + 2.0;
|
||||
let br = if p.side == designcraft_doc::PageSide::Left {
|
||||
egui::Rect::from_min_size(pr.left_top(), vec2(w, 10.0))
|
||||
} else {
|
||||
egui::Rect::from_min_size(pr.right_top() - vec2(w, 0.0), vec2(w, 10.0))
|
||||
};
|
||||
ui.painter().rect_filled(br, 0.0, Color32::WHITE);
|
||||
ui.painter().galley(br.min + vec2(1.0, -0.5), g, Color32::BLACK);
|
||||
}
|
||||
// Drag and drop: pages reorder, parents apply.
|
||||
resp.dnd_set_drag_payload(PageDrag::Page(abs));
|
||||
if resp.dragged() {
|
||||
drag_ghost(ui, &app.session.page_label(abs), &t);
|
||||
}
|
||||
if dragging && resp.contains_pointer() {
|
||||
ui.painter().rect_stroke(pr.expand(1.5), 0.0, Stroke::new(2.0, BADGE), StrokeKind::Outside);
|
||||
}
|
||||
if let Some(payload) = resp.dnd_release_payload::<PageDrag>() {
|
||||
drop = Some(((*payload).clone(), abs));
|
||||
}
|
||||
if resp.clicked() || resp.double_clicked() {
|
||||
ui.painter().rect_stroke(pr, 0.0, Stroke::new(0.5, Color32::from_gray(0x4a)), StrokeKind::Outside);
|
||||
if abs == 0 || doc.sections.iter().any(|s| s.start == abs) {
|
||||
let c = egui::pos2(pr.center().x, row.min.y + 4.0);
|
||||
ui.painter().add(egui::Shape::convex_polygon(
|
||||
vec![c + vec2(-4.5, -3.0), c + vec2(4.5, -3.0), c + vec2(0.0, 3.0)],
|
||||
t.icon,
|
||||
Stroke::NONE,
|
||||
));
|
||||
let marker =
|
||||
ui.interact(egui::Rect::from_center_size(c, vec2(12.0, 8.0)), ui.scope_id().with(("section", abs)), Sense::click());
|
||||
let label = format!(
|
||||
"{}: {}",
|
||||
crate::i18n::tr(&app.ui.language, "Numbering & Section Options…").trim_end_matches('…'),
|
||||
app.session.page_label(abs)
|
||||
);
|
||||
marker.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label));
|
||||
// A double click, or the keyboard or a screen reader activating it (a
|
||||
// single pointer click only selects, as on a page).
|
||||
let activated = marker.double_clicked() || marker.clicked() && !marker.clicked_by(egui::PointerButton::Primary);
|
||||
if activated {
|
||||
if editing_parents {
|
||||
let _ = app.run("layout.parents.edit", json!({"on": false}));
|
||||
// The canvas refits to the page's spread once it is centred.
|
||||
if let Some(v) = app.view_mut() {
|
||||
v.fitted = false;
|
||||
if let Some(view) = app.view_mut() {
|
||||
view.fitted = false;
|
||||
}
|
||||
}
|
||||
crate::canvas::go_to_page(app, abs);
|
||||
crate::menus::activate(app, "layout.section", &serde_json::Value::Null);
|
||||
}
|
||||
resp.context_menu(|ui| page_menu(app, ui, &doc, abs, si));
|
||||
}
|
||||
let label = spread_label(app, &doc, first, sp.pages.len());
|
||||
// White on the badge; the theme's text colour elsewhere, so it reads on a light panel.
|
||||
let badge = cur_spread == Some(si) && !editing_parents;
|
||||
let ink = if badge { Color32::WHITE } else { t.text_strong };
|
||||
let g = ui.painter().layout_no_wrap(label.clone(), crate::theme::semibold(11.0), ink);
|
||||
let c = egui::pos2(row.min.x + center, top + row_h + 11.0);
|
||||
let br = egui::Rect::from_center_size(c, vec2(g.size().x + 7.0, 12.5));
|
||||
if badge {
|
||||
ui.painter().rect_filled(br, 0.0, BADGE);
|
||||
// Parent prefix in the outer top corner (black on white).
|
||||
if let Some(pid) = p.parent
|
||||
&& let Some(pp) = doc.parents.iter().find(|x| x.id == pid)
|
||||
{
|
||||
let prefix = pp.parent.as_ref().map(|i| i.prefix.clone()).unwrap_or_default();
|
||||
let g = ui.painter().layout_no_wrap(prefix, crate::theme::semibold(9.5), Color32::BLACK);
|
||||
let w = g.size().x + 2.0;
|
||||
let br = if p.side == designcraft_doc::PageSide::Left {
|
||||
egui::Rect::from_min_size(pr.left_top(), vec2(w, 10.0))
|
||||
} else {
|
||||
egui::Rect::from_min_size(pr.right_top() - vec2(w, 0.0), vec2(w, 10.0))
|
||||
};
|
||||
ui.painter().rect_filled(br, 0.0, Color32::WHITE);
|
||||
ui.painter().galley(br.min + vec2(1.0, -0.5), g, Color32::BLACK);
|
||||
}
|
||||
ui.painter().galley(c - g.size() / 2.0, g, ink);
|
||||
let lr = ui.interact(br, ui.scope_id().with(("spread_label", si)), Sense::click());
|
||||
// A click goes to the spread, so assistive technology hears a button.
|
||||
lr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label));
|
||||
if lr.clicked() {
|
||||
// Drag and drop: pages reorder, parents apply.
|
||||
resp.dnd_set_drag_payload(PageDrag::Page(abs));
|
||||
if resp.dragged() {
|
||||
drag_ghost(ui, &app.session.page_label(abs), &t);
|
||||
}
|
||||
if dragging && resp.contains_pointer() {
|
||||
ui.painter().rect_stroke(pr.expand(1.5), 0.0, Stroke::new(2.0, BADGE), StrokeKind::Outside);
|
||||
}
|
||||
if let Some(payload) = resp.dnd_release_payload::<PageDrag>() {
|
||||
drop = Some(((*payload).clone(), abs));
|
||||
}
|
||||
if resp.clicked() || resp.double_clicked() {
|
||||
select_parent(app, None);
|
||||
if editing_parents {
|
||||
let _ = app.run("layout.parents.edit", json!({"on": false}));
|
||||
if let Some(view) = app.view_mut() {
|
||||
view.fitted = false;
|
||||
// The canvas refits to the page's spread once it is centred.
|
||||
if let Some(v) = app.view_mut() {
|
||||
v.fitted = false;
|
||||
}
|
||||
}
|
||||
crate::canvas::go_to_page(app, first);
|
||||
crate::canvas::go_to_page(app, abs);
|
||||
}
|
||||
resp.context_menu(|ui| page_menu(app, ui, &doc, &layouts, abs, si));
|
||||
}
|
||||
// Right-click on the empty part of the panel: the short menu.
|
||||
let rest = ui.available_rect_before_wrap();
|
||||
let (_, empty) = ui.allocate_exact_size(vec2(ui.available_width(), rest.height().max(8.0)), Sense::click());
|
||||
empty.context_menu(|ui| empty_menu(app, ui));
|
||||
},
|
||||
);
|
||||
let label = spread_label(app, &doc, first, sp.pages.len());
|
||||
// White on the badge; the theme's text colour elsewhere, so it reads on a light panel.
|
||||
let badge = cur_spread == Some(si) && !editing_parents;
|
||||
let ink = if badge { Color32::WHITE } else { t.text_strong };
|
||||
let g = ui.painter().layout_no_wrap(label.clone(), crate::theme::semibold(11.0), ink);
|
||||
let c = egui::pos2(row.min.x + center, top + row_h + 11.0);
|
||||
let br = egui::Rect::from_center_size(c, vec2(g.size().x + 7.0, 12.5));
|
||||
if badge {
|
||||
ui.painter().rect_filled(br, 0.0, BADGE);
|
||||
}
|
||||
ui.painter().galley(c - g.size() / 2.0, g, ink);
|
||||
let lr = ui.interact(br, ui.scope_id().with(("spread_label", si)), Sense::click());
|
||||
// A click goes to the spread, so assistive technology hears a button.
|
||||
lr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label));
|
||||
if lr.clicked() {
|
||||
if editing_parents {
|
||||
let _ = app.run("layout.parents.edit", json!({"on": false}));
|
||||
if let Some(view) = app.view_mut() {
|
||||
view.fitted = false;
|
||||
}
|
||||
}
|
||||
crate::canvas::go_to_page(app, first);
|
||||
}
|
||||
}
|
||||
};
|
||||
let area = if columns { egui::ScrollArea::both() } else { egui::ScrollArea::vertical() };
|
||||
area.id_salt("pages_spreads").max_height(pages_h).min_scrolled_height(pages_h).auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.add_space(4.0);
|
||||
if columns {
|
||||
// Alternate layouts side by side, as InDesign's: a column each, its name and size
|
||||
// over it, and a column to add one more.
|
||||
let scale = (th / doc.settings.page_height.max(1.0) as f32).min(1.8 * th / tallest as f32);
|
||||
let cur_layout = layout_index(&layouts, cur);
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(6.0, 0.0);
|
||||
for (k, l) in layouts.iter().enumerate() {
|
||||
let spreads = spread_range(&doc, l);
|
||||
let widest =
|
||||
doc.spreads.get(spreads.clone()).unwrap_or_default().iter().map(|sp| sp.bounds().width() as f32).fold(0.0, f32::max);
|
||||
let col_w = (widest * scale + 24.0).max(LAYOUT_COL_MIN);
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(col_w);
|
||||
layout_header(app, ui, &doc, &layouts, k, k == cur_layout, &t);
|
||||
draw_spreads(app, ui, spreads);
|
||||
});
|
||||
}
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(28.0, LAYOUT_HEADER_H), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 2.0, t.hover);
|
||||
}
|
||||
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "+", egui::FontId::proportional(15.0), t.text_strong);
|
||||
let label = crate::i18n::tr(&app.ui.language, "Create Alternate Layout…");
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
|
||||
if resp.on_hover_text(label).clicked() {
|
||||
open_create_alternate(app, &doc, &layouts, cur_layout);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
draw_spreads(app, ui, 0..doc.spreads.len());
|
||||
}
|
||||
// Right-click on the empty part of the panel: the short menu.
|
||||
let rest = ui.available_rect_before_wrap();
|
||||
let (_, empty) = ui.allocate_exact_size(vec2(ui.available_width(), rest.height().max(8.0)), Sense::click());
|
||||
empty.context_menu(|ui| empty_menu(app, ui));
|
||||
});
|
||||
};
|
||||
// The divider: drag it to show more or fewer parents.
|
||||
let divider = |app: &mut DesignApp, ui: &mut egui::Ui| {
|
||||
@@ -674,7 +731,8 @@ pub fn panel_menu(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let doc = st.doc.clone();
|
||||
let cur = crate::canvas::current_page(app).unwrap_or(0);
|
||||
let Some((si, _)) = doc.page_loc(cur) else { return };
|
||||
page_menu(app, ui, &doc, cur, si);
|
||||
let layouts = doc.layouts();
|
||||
page_menu(app, ui, &doc, &layouts, cur, si);
|
||||
}
|
||||
|
||||
/// Right-click on the panel's empty part, as InDesign's: the commands that need no page.
|
||||
@@ -710,7 +768,262 @@ pub fn open_panel_options(app: &mut DesignApp) {
|
||||
));
|
||||
}
|
||||
|
||||
fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Document, abs: usize, si: usize) {
|
||||
/// The spreads holding layout `l`'s pages (every layout starts a spread of its own).
|
||||
fn spread_range(doc: &designcraft_doc::Document, l: &designcraft_doc::LayoutRange) -> std::ops::Range<usize> {
|
||||
let first = doc.page_loc(l.start).map_or(doc.spreads.len(), |(si, _)| si);
|
||||
let end = l.end.checked_sub(1).and_then(|last| doc.page_loc(last)).map_or(first, |(si, _)| si.saturating_add(1));
|
||||
first..end.max(first)
|
||||
}
|
||||
|
||||
/// The size of layout `l`'s first page, in points.
|
||||
fn layout_size(doc: &designcraft_doc::Document, l: &designcraft_doc::LayoutRange) -> (f64, f64) {
|
||||
doc.page(l.start).map_or((doc.settings.page_width, doc.settings.page_height), |p| (p.width, p.height))
|
||||
}
|
||||
|
||||
/// An alternate layout column's header: its name and page size, and a menu (▾, or a right-click)
|
||||
/// to create, duplicate, change or delete layouts. Double-click: Alternate Layout Options.
|
||||
fn layout_header(
|
||||
app: &mut DesignApp,
|
||||
ui: &mut egui::Ui,
|
||||
doc: &designcraft_doc::Document,
|
||||
layouts: &[designcraft_doc::LayoutRange],
|
||||
k: usize,
|
||||
current: bool,
|
||||
t: &Tokens,
|
||||
) {
|
||||
let Some(l) = layouts.get(k) else { return };
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), LAYOUT_HEADER_H), Sense::click());
|
||||
ui.painter().rect_filled(r.shrink2(vec2(0.0, 2.0)), 2.0, if current { t.row_selected } else { t.panel_darker });
|
||||
let (w, h) = layout_size(doc, l);
|
||||
let menu_r = egui::Rect::from_min_size(egui::pos2(r.max.x - 20.0, r.min.y + 4.0), vec2(18.0, LAYOUT_HEADER_H - 8.0));
|
||||
let text_w = (r.width() - 26.0).max(8.0);
|
||||
let name = ui.painter().layout(l.name.clone(), crate::theme::semibold(11.0), t.text_strong, text_w);
|
||||
let name =
|
||||
if name.rows.len() > 1 { ui.painter().layout_no_wrap(truncate(&l.name, 14), crate::theme::semibold(11.0), t.text_strong) } else { name };
|
||||
ui.painter().galley(r.min + vec2(5.0, 5.0), name, t.text_strong);
|
||||
ui.painter().text(
|
||||
r.min + vec2(5.0, 20.0),
|
||||
egui::Align2::LEFT_TOP,
|
||||
format!("{} × {}", w.round(), h.round()),
|
||||
egui::FontId::proportional(10.0),
|
||||
t.text_dim,
|
||||
);
|
||||
let menu = ui.interact(menu_r, ui.scope_id().with(("layout_menu", k)), Sense::click());
|
||||
if menu.hovered() {
|
||||
ui.painter().rect_filled(menu_r, 2.0, t.hover);
|
||||
}
|
||||
ui.painter().text(menu_r.center(), egui::Align2::CENTER_CENTER, "▾", egui::FontId::proportional(12.0), t.text_strong);
|
||||
let label = crate::i18n::tr(&app.ui.language, "Layout Options");
|
||||
menu.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("{label}: {}", l.name)));
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, current, l.name.clone()));
|
||||
egui::Popup::menu(&menu).show(|ui| layout_menu(app, ui, doc, layouts, k));
|
||||
if resp.double_clicked() {
|
||||
open_layout_options(app, doc, layouts, k);
|
||||
} else if resp.clicked() {
|
||||
// As a page click: leave Edit Parents first, so the view moves on document pages.
|
||||
if app.session.active().is_some_and(|st| st.editing_parents) {
|
||||
let _ = app.run("layout.parents.edit", json!({"on": false}));
|
||||
if let Some(v) = app.view_mut() {
|
||||
v.fitted = false;
|
||||
}
|
||||
}
|
||||
crate::canvas::go_to_page(app, l.start);
|
||||
}
|
||||
resp.context_menu(|ui| layout_menu(app, ui, doc, layouts, k));
|
||||
}
|
||||
|
||||
/// The layout (index into `layouts`) holding absolute page `abs`.
|
||||
pub(crate) fn layout_index(layouts: &[designcraft_doc::LayoutRange], abs: usize) -> usize {
|
||||
layouts.iter().rposition(|l| l.start <= abs).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// At most `n` characters of `s`, with an ellipsis when cut.
|
||||
fn truncate(s: &str, n: usize) -> String {
|
||||
if s.chars().count() <= n { s.to_string() } else { format!("{}…", s.chars().take(n.saturating_sub(1)).collect::<String>()) }
|
||||
}
|
||||
|
||||
/// The commands on layout `k`: in its column header's menu and in the panel menu.
|
||||
fn layout_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Document, layouts: &[designcraft_doc::LayoutRange], k: usize) {
|
||||
let count = layouts.len();
|
||||
let lang = app.ui.language.clone();
|
||||
let item = |ui: &mut egui::Ui, label: &str, on: bool| {
|
||||
ui.add_enabled(on, egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&lang, label)))).clicked()
|
||||
};
|
||||
if item(ui, "Create Alternate Layout…", true) {
|
||||
open_create_alternate(app, doc, layouts, k);
|
||||
ui.close();
|
||||
}
|
||||
if item(ui, "Duplicate Alternate Layout", true) {
|
||||
let _ = app.run("layout.alternate.duplicate", json!({"layout": k}));
|
||||
ui.close();
|
||||
}
|
||||
if item(ui, "Alternate Layout Options…", true) {
|
||||
open_layout_options(app, doc, layouts, k);
|
||||
ui.close();
|
||||
}
|
||||
if item(ui, "Export Layout…", true) {
|
||||
let name = layouts.get(k).map(|l| l.name.clone());
|
||||
crate::export_batch_dialog::open(app, name.map(|n| vec![n]));
|
||||
ui.close();
|
||||
}
|
||||
if item(ui, "Delete Alternate Layout", count > 1) {
|
||||
if app.run("layout.alternate.delete", json!({"layout": k})).is_ok() {
|
||||
app.views.clear();
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Create Alternate Layout…, from layout `k`, at its pages' other orientation.
|
||||
pub(crate) fn open_create_alternate(app: &mut DesignApp, doc: &designcraft_doc::Document, layouts: &[designcraft_doc::LayoutRange], k: usize) {
|
||||
let Some(l) = layouts.get(k) else { return };
|
||||
let (w, h) = layout_size(doc, l);
|
||||
let (nw, nh) = (h, w);
|
||||
let name = format!("{} {}", l.name, if nw > nh { "H" } else { "V" });
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new("cmd:layout.createAlternate", json!({"name": name, "width": nw, "height": nh, "from": l.name})));
|
||||
}
|
||||
|
||||
/// Alternate Layout Options… for layout `k`: its name and page size.
|
||||
pub(crate) fn open_layout_options(app: &mut DesignApp, doc: &designcraft_doc::Document, layouts: &[designcraft_doc::LayoutRange], k: usize) {
|
||||
let Some(l) = layouts.get(k) else { return };
|
||||
let (w, h) = layout_size(doc, l);
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new(
|
||||
"cmd:layout.alternate.options",
|
||||
json!({"layout": l.name, "name": l.name, "width": w, "height": h, "sizeWas": [w, h]}),
|
||||
));
|
||||
}
|
||||
|
||||
/// Parent Options… for parent `id`: prefix, name and Based on Parent.
|
||||
pub(crate) fn open_parent_options(app: &mut DesignApp, doc: &designcraft_doc::Document, id: SpreadId) {
|
||||
let Some(info) = doc.parent_index(id).and_then(|i| doc.parents.get(i)).and_then(|p| p.parent.as_ref()) else { return };
|
||||
let bases: Vec<serde_json::Value> =
|
||||
doc.parents.iter().filter(|p| p.id != id).filter_map(|p| p.parent.as_ref().map(|i| json!([p.id.0.to_string(), i.label()]))).collect();
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new(
|
||||
"parentOptions",
|
||||
json!({
|
||||
"parent": id.0,
|
||||
"prefix": info.prefix,
|
||||
"name": info.name,
|
||||
"basedOn": info.based_on.map(|b| b.0.to_string()).unwrap_or_default(),
|
||||
"bases": bases,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
/// The parent selected in the parents list, when it belongs to the active document.
|
||||
pub(crate) fn selected_parent(app: &DesignApp) -> Option<SpreadId> {
|
||||
let uid = app.session.active()?.uid;
|
||||
app.ui.pages_selected_parent.filter(|(u, _)| *u == uid).map(|(_, id)| id)
|
||||
}
|
||||
|
||||
/// Select parent `id` (None: none) of the active document in the parents list.
|
||||
pub(crate) fn select_parent(app: &mut DesignApp, id: Option<SpreadId>) {
|
||||
app.ui.pages_selected_parent = id.zip(app.session.active().map(|st| st.uid)).map(|(id, uid)| (uid, id));
|
||||
}
|
||||
|
||||
/// The parent the panel menu's parent commands act on: the one selected in the list, or the one
|
||||
/// the canvas shows while parents are edited.
|
||||
pub(crate) fn target_parent(app: &DesignApp, doc: &designcraft_doc::Document) -> Option<SpreadId> {
|
||||
if let Some(id) = selected_parent(app).filter(|id| doc.parent_index(*id).is_some()) {
|
||||
return Some(id);
|
||||
}
|
||||
if !app.session.active().is_some_and(|st| st.editing_parents) {
|
||||
return None;
|
||||
}
|
||||
let layout = designcraft_tools::CanvasLayout::new(doc, true);
|
||||
let i = crate::canvas::current_slot(app, &layout).and_then(|i| layout.slots.get(i)).and_then(|s| match s.spread {
|
||||
SpreadRef::Parent(i) => Some(i),
|
||||
SpreadRef::Doc(_) => None,
|
||||
})?;
|
||||
doc.parents.get(i).map(|p| p.id)
|
||||
}
|
||||
|
||||
/// One parent command: Duplicate Parent Spread, Parent Options… or Delete Parent.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum ParentAction {
|
||||
Duplicate,
|
||||
Options,
|
||||
Delete,
|
||||
}
|
||||
|
||||
impl ParentAction {
|
||||
const ALL: [ParentAction; 3] = [ParentAction::Duplicate, ParentAction::Options, ParentAction::Delete];
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
ParentAction::Duplicate => "Duplicate Parent Spread",
|
||||
ParentAction::Options => "Parent Options…",
|
||||
ParentAction::Delete => "Delete Parent",
|
||||
}
|
||||
}
|
||||
|
||||
fn run(self, app: &mut DesignApp, doc: &designcraft_doc::Document, id: SpreadId) {
|
||||
match self {
|
||||
ParentAction::Duplicate => {
|
||||
if let Ok(r) = app.run("layout.parents.duplicate", json!({"parent": id.0})) {
|
||||
select_parent(app, r.get("id").and_then(serde_json::Value::as_u64).map(SpreadId));
|
||||
}
|
||||
}
|
||||
ParentAction::Options => open_parent_options(app, doc, id),
|
||||
ParentAction::Delete => {
|
||||
if app.run("layout.parents.delete", json!({"id": id.0})).is_ok() {
|
||||
app.views.clear();
|
||||
if selected_parent(app) == Some(id) {
|
||||
select_parent(app, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The parent commands. `target` set: each acts on that parent (named in the item when `named`);
|
||||
/// unset: each is a submenu of the parents.
|
||||
fn parent_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Document, target: Option<SpreadId>, named: bool) {
|
||||
let lang = app.ui.language.clone();
|
||||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "New Parent…"))).clicked() {
|
||||
crate::menus::activate(app, "layout.parents.new", &serde_json::Value::Null);
|
||||
ui.close();
|
||||
}
|
||||
let label_of = |id: SpreadId| doc.parent_index(id).and_then(|i| doc.parents.get(i)).and_then(|p| p.parent.as_ref()).map(|i| i.label());
|
||||
for action in ParentAction::ALL {
|
||||
let label = crate::i18n::tr(&lang, action.label()).to_string();
|
||||
match target {
|
||||
Some(id) => {
|
||||
let shown = match label_of(id) {
|
||||
Some(name) if named => format!("{} “{name}”", label.trim_end_matches('…')) + if label.ends_with('…') { "…" } else { "" },
|
||||
_ => label,
|
||||
};
|
||||
if ui.button(crate::rtl::widget(ui, shown)).clicked() {
|
||||
action.run(app, doc, id);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ui.add_enabled_ui(!doc.parents.is_empty(), |ui| {
|
||||
crate::menus::menu_button(ui, crate::rtl::widget(ui, label.trim_end_matches('…')), |ui| {
|
||||
for p in &doc.parents {
|
||||
let Some(name) = p.parent.as_ref().map(|i| i.label()) else { continue };
|
||||
if ui.button(name).clicked() {
|
||||
action.run(app, doc, p.id);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn page_menu(
|
||||
app: &mut DesignApp,
|
||||
ui: &mut egui::Ui,
|
||||
doc: &designcraft_doc::Document,
|
||||
layouts: &[designcraft_doc::LayoutRange],
|
||||
abs: usize,
|
||||
si: usize,
|
||||
) {
|
||||
let lang = app.ui.language.clone();
|
||||
let tr = |s: &str| crate::i18n::tr(&lang, s).to_owned();
|
||||
let pages_in_spread = doc.spreads.get(si).map_or(1, |sp| sp.pages.len());
|
||||
@@ -742,10 +1055,8 @@ fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Docu
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
if item(ui, "New Parent…") {
|
||||
crate::menus::activate(app, "layout.parents.new", &serde_json::Value::Null);
|
||||
ui.close();
|
||||
}
|
||||
let target = target_parent(app, doc);
|
||||
parent_menu(app, ui, doc, target, true);
|
||||
crate::menus::menu_button(ui, crate::rtl::widget(ui, tr("Apply Parent to Pages")), |ui| {
|
||||
if ui.button(crate::rtl::widget(ui, tr("[None]"))).clicked() {
|
||||
let _ = app.run("layout.pages.applyParent", json!({"pages": [abs], "parent": null}));
|
||||
@@ -772,10 +1083,7 @@ fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Docu
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
if item(ui, "Create Alternate Layout…") {
|
||||
crate::menus::activate(app, "layout.createAlternate", &serde_json::Value::Null);
|
||||
ui.close();
|
||||
}
|
||||
layout_menu(app, ui, doc, layouts, layout_index(layouts, abs));
|
||||
if item(ui, "Numbering & Section Options…") {
|
||||
crate::menus::activate(app, "layout.section", &serde_json::Value::Null);
|
||||
ui.close();
|
||||
|
||||
@@ -111,7 +111,8 @@ fn menu_harness(app: DesignApp, abs: usize, si: usize) -> Harness<'static, Desig
|
||||
return;
|
||||
}
|
||||
let doc = app.session.doc().unwrap().doc.clone();
|
||||
page_menu(app, ui, &doc, abs, si);
|
||||
let layouts = doc.layouts();
|
||||
page_menu(app, ui, &doc, &layouts, abs, si);
|
||||
},
|
||||
app,
|
||||
);
|
||||
@@ -326,6 +327,150 @@ fn parent_thumbnails_follow_panel_options() {
|
||||
assert_eq!(parent_thumb_heights(false), Vec::<u32>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parents_are_added_duplicated_and_renamed_from_the_panel() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap();
|
||||
let mut h = harness(app);
|
||||
// One click on New Parent adds B-Parent and selects it.
|
||||
h.get_by_label("New Parent").click();
|
||||
h.run_steps(3);
|
||||
let doc = h.state().session.doc().unwrap().doc.clone();
|
||||
let b = doc.parents.iter().find(|p| p.parent.as_ref().is_some_and(|i| i.label() == "B-Parent")).map(|p| p.id);
|
||||
assert!(b.is_some());
|
||||
assert_eq!(selected_parent(h.state()), b);
|
||||
// Its menu duplicates it as C-Parent.
|
||||
h.get_by_label("B-Parent").click_secondary();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Duplicate Parent Spread").click();
|
||||
h.run_steps(3);
|
||||
let labels: Vec<String> = h.state().session.doc().unwrap().doc.parents.iter().filter_map(|p| p.parent.as_ref().map(|i| i.label())).collect();
|
||||
assert_eq!(labels, ["A-Parent", "B-Parent", "C-Parent"]);
|
||||
// Parent Options… renames prefix and name.
|
||||
h.get_by_label("C-Parent").click_secondary();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Parent Options…").click();
|
||||
h.run_steps(3);
|
||||
let app = h.state_mut();
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
assert_eq!(d.id, "parentOptions");
|
||||
assert_eq!((d.s("prefix"), d.s("name")), ("C".into(), "Parent".into()));
|
||||
d.fields.insert("prefix".into(), json!("CV"));
|
||||
d.fields.insert("name".into(), json!("Cover"));
|
||||
d.fields.insert("basedOn".into(), json!(b.unwrap().0.to_string()));
|
||||
crate::dialogs::confirm(app).unwrap();
|
||||
let doc = &app.session.doc().unwrap().doc;
|
||||
let info = doc.parents[2].parent.as_ref().unwrap();
|
||||
assert_eq!((info.label(), info.based_on), ("CV-Cover".into(), b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_panel_menu_names_the_selected_parent_or_lists_the_parents() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap();
|
||||
let h = menu_harness(app, 0, 0);
|
||||
assert!(h.query_by_label("Parent Options").is_some(), "no parent selected: a submenu of the parents");
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap();
|
||||
let first = app.session.doc().unwrap().doc.parents[0].id;
|
||||
select_parent(&mut app, Some(first));
|
||||
let mut h = menu_harness(app, 0, 0);
|
||||
assert!(h.query_by_label("Delete Parent “A-Parent”").is_some());
|
||||
h.get_by_label("Duplicate Parent Spread “A-Parent”").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.doc().unwrap().doc.parents.len(), 2);
|
||||
// Duplicating selected the copy, so the menu now names it.
|
||||
assert!(h.query_by_label("Parent Options “B-Parent”…").is_some());
|
||||
h.get_by_label("Parent Options “B-Parent”…").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().ui.dialog.as_ref().is_some_and(|d| d.id == "parentOptions" && d.s("prefix") == "B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternate_layouts_show_as_columns_with_their_own_menus() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"pages": 2, "width": 612, "height": 792})).unwrap();
|
||||
let single = sized_harness(app, vec2(600.0, 520.0));
|
||||
assert!(single.query_by_label("Layout 1").is_none(), "one layout: no column headers");
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"pages": 2, "width": 612, "height": 792})).unwrap();
|
||||
app.run("layout.createAlternate", json!({"name": "Tablet H", "width": 1024, "height": 768})).unwrap();
|
||||
let mut h = sized_harness(app, vec2(600.0, 520.0));
|
||||
let (a, b) = (h.get_by_label("Layout 1").rect(), h.get_by_label("Tablet H").rect());
|
||||
assert!(b.min.x > a.max.x - 1.0, "the alternate layout is a column to the right: {a:?} {b:?}");
|
||||
// The + column creates another, from the current layout.
|
||||
h.get_by_label("Create Alternate Layout…").click();
|
||||
h.run_steps(3);
|
||||
let d = h.state().ui.dialog.clone().unwrap();
|
||||
assert_eq!(d.id, "cmd:layout.createAlternate");
|
||||
assert_eq!(d.s("from"), "Layout 1");
|
||||
h.state_mut().ui.dialog = None;
|
||||
// The header's menu duplicates and deletes.
|
||||
h.get_by_label("Layout Options: Tablet H").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Duplicate Alternate Layout").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.doc().unwrap().doc.layouts().len(), 3);
|
||||
h.get_by_label("Layout Options: Tablet H").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Delete Alternate Layout").click();
|
||||
h.run_steps(3);
|
||||
let names: Vec<String> = h.state().session.doc().unwrap().doc.layouts().into_iter().map(|l| l.name).collect();
|
||||
assert_eq!(names, ["Layout 1", "Tablet H copy"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_an_alternate_layout_keeps_its_page_sizes() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"pages": 1, "width": 600, "height": 800, "facingPages": false})).unwrap();
|
||||
app.run("layout.createAlternate", json!({"name": "Wide", "width": 900, "height": 600})).unwrap();
|
||||
app.run("layout.pages.insert", json!({"after": 1, "count": 1})).unwrap();
|
||||
app.run("layout.pageSize", json!({"pages": [3], "width": 400, "height": 400})).unwrap();
|
||||
let sizes = |app: &DesignApp| (1..3).map(|i| app.session.doc().unwrap().doc.page(i).map(|p| (p.width, p.height))).collect::<Vec<_>>();
|
||||
let open = |app: &mut DesignApp| {
|
||||
let doc = app.session.doc().unwrap().doc.clone();
|
||||
crate::panels::pages::open_layout_options(app, &doc, &doc.layouts(), 1);
|
||||
};
|
||||
// The dialog shows the first page's size (900 × 600); renaming leaves the 400 × 400 page.
|
||||
open(&mut app);
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Landscape"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(sizes(&app), vec![Some((900.0, 600.0)), Some((400.0, 400.0))]);
|
||||
assert_eq!(app.session.doc().unwrap().doc.layouts()[1].name, "Landscape");
|
||||
// A changed width still resizes the layout's pages.
|
||||
open(&mut app);
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("width".into(), json!("1000"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert!(sizes(&app).iter().all(|s| s.is_some_and(|(w, _)| (w - 1000.0).abs() < 1e-6)), "{:?}", sizes(&app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_menu_options_open_the_pages_panel_dialogs() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap();
|
||||
app.run("layout.parents.new", json!({"prefix": "B"})).unwrap();
|
||||
app.run("app.parentOptions", json!({})).unwrap();
|
||||
assert!(app.ui.dialog.as_ref().is_some_and(|d| d.id == "parentOptions" && d.s("prefix") == "A"), "no selection: the first parent");
|
||||
let b = app.session.doc().unwrap().doc.parents[1].id;
|
||||
select_parent(&mut app, Some(b));
|
||||
app.run("app.parentOptions", json!({})).unwrap();
|
||||
assert!(app.ui.dialog.as_ref().is_some_and(|d| d.s("prefix") == "B"));
|
||||
// A prefix the engine refuses (A is taken): the dialog stays, with what was typed and why.
|
||||
if let Some(d) = app.ui.dialog.as_mut() {
|
||||
d.fields.insert("prefix".into(), json!("A"));
|
||||
}
|
||||
assert!(crate::dialogs::confirm(&mut app).is_err());
|
||||
let d = app.ui.dialog.as_ref().expect("the dialog stays open");
|
||||
assert!(d.id == "parentOptions" && d.s("prefix") == "A" && !d.s("status").is_empty());
|
||||
app.ui.dialog = None;
|
||||
app.run("layout.createAlternate", json!({"name": "Wide", "width": 900, "height": 600})).unwrap();
|
||||
app.run("app.alternateLayoutOptions", json!({})).unwrap();
|
||||
assert!(app.ui.dialog.as_ref().is_some_and(|d| d.id == "cmd:layout.alternate.options" && d.s("layout") == "Layout 1"));
|
||||
// A selection belongs to its document.
|
||||
app.run("file.new", json!({"pages": 1})).unwrap();
|
||||
assert_eq!(selected_parent(&app), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_panel_menu_follows_indesigns_order() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
|
||||
@@ -210,6 +210,11 @@ pub struct UiState {
|
||||
/// The Pages panel's parents part as its divider was last dragged, and the height the two
|
||||
/// parts shared then (None: a few rows).
|
||||
pub pages_parents_height: Option<[f32; 2]>,
|
||||
/// The parent selected (clicked) in the Pages panel's parents list: what the panel menu's
|
||||
/// Duplicate Parent Spread, Parent Options and Delete Parent act on, with the uid of the
|
||||
/// document it belongs to (a selection never carries over to another document or session).
|
||||
#[serde(skip)]
|
||||
pub pages_selected_parent: Option<(u64, designcraft_doc::SpreadId)>,
|
||||
/// The Pages panel's Panel Options.
|
||||
pub pages_panel: panels::pages::PanelOptions,
|
||||
/// Edit › Color Settings (re-applied at start).
|
||||
@@ -235,6 +240,8 @@ pub struct UiState {
|
||||
pub flattener: String,
|
||||
/// File › Export PDF… options (persisted; see `PdfExportSettings`).
|
||||
pub pdf_export: PdfExportSettings,
|
||||
/// File › Export Layouts and Versions…: the last job exported (persisted).
|
||||
pub export_batch: Option<designcraft_engine::cmd::export_batch::ExportJob>,
|
||||
/// The Preview checkbox of Paragraph/Character Style Options, remembered between openings.
|
||||
pub style_preview: bool,
|
||||
/// View › Separations Preview: the plate shown and the ink limit; `None` is off.
|
||||
@@ -412,6 +419,7 @@ impl Default for UiState {
|
||||
hidden_panels: 0,
|
||||
skip_delete_pages_warning: false,
|
||||
pages_parents_height: None,
|
||||
pages_selected_parent: None,
|
||||
pages_panel: Default::default(),
|
||||
color_settings: None,
|
||||
color_profiles: Vec::new(),
|
||||
@@ -421,6 +429,7 @@ impl Default for UiState {
|
||||
language: String::new(),
|
||||
flattener: String::new(),
|
||||
pdf_export: PdfExportSettings::default(),
|
||||
export_batch: None,
|
||||
style_preview: false,
|
||||
separations: None,
|
||||
display_quality: designcraft_render::DisplayQuality::High,
|
||||
|
||||
+7
-4
@@ -121,7 +121,10 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
|
||||
the PDF/X-1a flattener as RGB images; #159, #32.
|
||||
- **Have:** PDF/X-4 with output intent and a validator (`#200`), PDF/X-1a:2003 through
|
||||
craft-prepress's flattener with its own checks, PDF/A-2b, tagged PDF, CMYK and spot, marks and
|
||||
bleed, the options dialog with remembered settings (#196).
|
||||
bleed, the options dialog with remembered settings (#196). File ▸ Export Layouts and Versions
|
||||
(`file.exportBatch`): several outputs at once (PDF with any marks/bleed, PNG, JPEG), per
|
||||
alternate layout or combined, in a folder per version, saved as export presets
|
||||
(`file.saveExportPreset`) — DesignCraft's own presets, not Adobe `.joboptions`.
|
||||
- **Estimate:** 30–60 h. **Doc:** [file-format-parity.md](file-format-parity.md)
|
||||
|
||||
### 28. Placed-PDF font repair for PDF/X-4 (FF)
|
||||
@@ -189,9 +192,9 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
|
||||
Profile / Edit Original, File ▸ Open PDF / Document and Print Presets / User
|
||||
Settings, Type ▸ Size presets, Table ▸ Table Options / Cell Options submenus, Paste Before/After,
|
||||
Convert Columns, Object ▸ Interactive ▸ Convert to (form fields),
|
||||
View ▸ Extras badges and Story Editor options (Show/Hide Depth Ruler is there). Pages panel ▸ Parent Options (Based on Parent is
|
||||
the `layout.parents.options` command only); overriding a base parent's items on a parent spread
|
||||
itself (overrides work on document pages only).
|
||||
View ▸ Extras badges and Story Editor options (Show/Hide Depth Ruler is there). Overriding a
|
||||
base parent's items on a parent spread itself (overrides work on document pages only); Save
|
||||
as Parent; new parents for an alternate layout (its pages keep their source pages' parents).
|
||||
- **Estimate:** 40–70 h (most are thin wrappers over existing engine features).
|
||||
- **Doc:** [ui-parity.md](ui-parity.md)
|
||||
|
||||
|
||||
@@ -309,6 +309,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
|
||||
- Gradient Feather (⇧G tool, `object.gradientFeather`): objects fade along a linear or radial opacity gradient, on screen and as a soft mask in PDF.
|
||||
- Script Label (`object.label`, `object.findByLabel`), alt text (`object.altText`), Isolate Blending / Knockout Group (`object.transparencyGroup`; on screen, PDF isolates), Table ▸ Sort (`table.sortRows`, numeric-aware, header/footer fixed).
|
||||
- File ▸ Export Text (`file.exportText`): a story as Text Only or RTF (fonts, styles, alignment, indents, tables as tab-separated rows); Export EPUB now asks for a path in the UI.
|
||||
- Export Layouts and Versions (`file.exportBatch`, File ▸ Export Layouts and Versions…, a layout column's ▾ ▸ Export Layout…): one export writes every chosen alternate layout (a file per layout, or all in one PDF) in several versions at once — PDF with any printer's marks/bleed/standard, PNG or JPEG at a resolution — each version in its own folder, file names from a template (`{document} - {layout}`, `{version}`, `{date}`). The dialog previews the files it will write; jobs save as export presets (`file.saveExportPreset`, built-ins: Print + Screen, Press PDF per Layout, Images per Layout, All Layouts in One PDF). Print (`file.print {layouts, separateJobs}`) prints chosen layouts, together or as a job each.
|
||||
- Tagged PDF (`file.exportPdf {tagged}`, on by default from the UI): structure tree with stories as paragraphs in reading order, figures carrying their alt text, parent-page items and printer's marks as artifacts.
|
||||
- Global Light (`object.globalLight`, `globalLight` on drop/inner shadows; Use Global Light in the Effects panel) and Generate Static Caption (`object.caption`: template over name, path, alt text, label, effective ppi, dimensions, format; any side, offset, style).
|
||||
- Control panel scale X/Y, rotation and shear fields (`transform.info`, absolute `transform.set {scaleX, scaleY, rotation, shear}` about the reference point) and Preferences › Transformations are Totals for nested objects and placed graphics.
|
||||
@@ -377,7 +378,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
|
||||
- Graphic cells (`table.placeGraphic`, `table.textCell`, Table › Convert Cell Type, Table panel): images in table cells, fitted or filled, clipped to the cell on screen and in PDF; IDML export writes them as an inline graphic in the cell.
|
||||
- Live Distribute (Space while dragging a selection handle; `transform.resize {distribute}`): objects keep their size and spread with the bounds.
|
||||
- Flex Layout (InDesign 2026; `flex.add`, `flex.convert`, `flex.remove`, `flex.set`, `flex.reorder`, Window › Flex Layout and the Properties panel's Flex Layout section): a frame lays out its objects in rows or columns by direction, wrap, justification (Flex Start, Center, Flex End, Space Between, Space Around, Space Evenly), the alignment grid, Fixed/Auto width and height, padding and spacing; aligned objects are detected on Add (direction, Auto, Space Between); resizing a container reflows it; dragging an object in one reorders it. Not yet: item sizing (Fixed/Auto/Fill Space per object), object styles with Flex Layout settings, IDML (a container exports as a frame with pasted-in objects), nested selection cues on the canvas.
|
||||
- Liquid Layout (`liquid.pageRule/object`, `guide.liquid`, `layout.createAlternate`, Liquid Layout panel): scale / re-center / guide-based / object-based page rules applied on page resize and Document Setup; dashed liquid guides; alternate layouts as resized page copies in a named section. Text in an alternate layout stays linked to the original stories (Links panel shows them out of date; `story.updateLink`).
|
||||
- Liquid Layout (`liquid.pageRule/object`, `guide.liquid`, `layout.createAlternate`, Liquid Layout panel): scale / re-center / guide-based / object-based page rules applied on page resize and Document Setup; dashed liquid guides; alternate layouts (`layout.createAlternate {from}`, `layout.alternate.duplicate/delete/options`): a layout is a run of pages that starts a section and a spread of its own; the Pages panel shows each as a column to the right, with a header menu to create, duplicate, rename/resize or delete it. Inserting or deleting pages keeps each layout's pages its own. Text in an alternate layout stays linked to the original stories (Links panel shows them out of date; `story.updateLink`).
|
||||
- Split Window / New Window (`window.split`, `window.newWindow`, Window ▸ Arrange): two side-by-side views of the document with their own zoom, scroll and render cache. New Window puts the second view in its own OS window (an egui viewport; a floating window on the web).
|
||||
- Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole).
|
||||
- Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 `<video>`/`<audio>` in EPUB. Interactive PDF export (`file.exportPdf {media: true}`, File ▸ Export PDF (Interactive)) embeds each file and plays it in a Screen annotation through a rendition action (controls, loop, play on page load). No in-app playback.
|
||||
@@ -452,7 +453,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
|
||||
- Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots; with text selected (or text frames selected with Formatting Affects Text on) it shows the text's stroke weight, colour and Stroke Behind Fill.
|
||||
- Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document; right-click a ruler to pick its units, the horizontal and vertical rulers each have their own); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands.
|
||||
- Parent item overrides: Cmd+Shift-click a parent item on a page (or Override All Parent Page Items, ⌥⇧⌘L) makes an editable local copy that hides the parent's; Remove All Local Overrides and Detach All Objects from Parent in the Pages panel menu.
|
||||
- Based on Parent (`layout.parents.options {parent, basedOn}`): a parent shows its base parent's items under its own, on its pages and on the parent itself; chains work, cycles are refused, and items from any parent in the chain can be overridden. IDML carries it as the parent pages' `AppliedMaster`; a cycle or missing base in a file is cleared with a warning on open. The Pages panel has no Parent Options dialog yet.
|
||||
- Based on Parent (`layout.parents.options {parent, basedOn}`): a parent shows its base parent's items under its own, on its pages and on the parent itself; chains work, cycles are refused, and items from any parent in the chain can be overridden. IDML carries it as the parent pages' `AppliedMaster`; a cycle or missing base in a file is cleared with a warning on open. The Pages panel's Parent Options dialog sets prefix (1–4 characters), name and Based on Parent; New Parent (a "+ New Parent" row, the next free letter), Duplicate Parent Spread and Delete Parent are in the parents' context menu and the panel menu.
|
||||
- Place PDF: placed PDF pages are sized by their crop box, drawn on screen through hayro (with mip levels) and embedded in exported PDFs as vector form XObjects (krilla), `<PDF>` in IDML.
|
||||
- Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation.
|
||||
- Table of contents (Layout ▸ Table of Contents dialog: included styles, per-style entry style, page number position and its character style, separator and its character style, alphabetical sorting and level, title style, Replace Existing; generate/update, dot leaders; the definition round-trips through IDML as a TOC style) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page; Convert Variable to Text turns the selected instances, or every instance of one variable, into the text they show on their page.
|
||||
|
||||
Reference in New Issue
Block a user