mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-25 18:56:50 -06:00
A: Primary Text Frame and Smart Text Reflow
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
20ef6c2c4e
commit
8bfbbe7187
@@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
||||
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
|
||||
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
|
||||
- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`).
|
||||
- Primary Text Frame (New Document) and Smart Text Reflow: pages are added while the primary story oversets and empty ones at the end removed, in the same undo step (Preferences › Type).
|
||||
- Glyphs panel: every character of any font (rendered by our renderer), search by character or U+code, click to insert, recently used.
|
||||
- UI scaling (Preferences › Interface, 50–200%; `window.uiScale`); the window title steps aside when the bar is crowded.
|
||||
- Underline / Strikethrough Options: weight, offset, colour and tint (Character panel; screen, PDF and IDML).
|
||||
|
||||
+30
-1
@@ -11,7 +11,7 @@ use crate::ids::{ItemId, LayerId, PageId, SpreadId};
|
||||
use crate::item::Item;
|
||||
use crate::page::{Columns, Margins, Page, PageSide, ParentInfo, Section, Spread};
|
||||
use crate::styles::Styles;
|
||||
use crate::{DocError, DocSettings, Document, Intent, LAYER_COLORS, Layer, Result};
|
||||
use crate::{DocError, DocSettings, Document, Intent, LAYER_COLORS, Layer, ParaFormat, Result, SpreadRef};
|
||||
|
||||
/// A New Document preset.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -164,9 +164,38 @@ impl Document {
|
||||
let pages: Vec<Page> = (0..nd.pages.max(1)).map(|_| d.make_page(Some(parent_id), nd.columns, nd.gutter, nd.margins)).collect();
|
||||
d.spreads = vec![Arc::new(Spread { id: SpreadId(d.alloc()), pages, items: vec![], parent: None, allow_shuffle: true })];
|
||||
d.repaginate();
|
||||
if nd.primary_text_frame {
|
||||
d.add_primary_frames(nd.columns, nd.gutter);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Primary Text Frame: a margin-sized frame on every page, threaded into one story.
|
||||
fn add_primary_frames(&mut self, columns: u32, gutter: f64) {
|
||||
let layer = self.default_layer();
|
||||
let mut prev: Option<ItemId> = None;
|
||||
let mut story = None;
|
||||
for page in 0..self.page_count() {
|
||||
let Some((si, pi)) = self.page_loc(page) else { continue };
|
||||
let r = self.spreads[si].pages[pi].margin_rect();
|
||||
let Ok((fid, sid)) = self.add_text_frame(SpreadRef::Doc(si), r, layer, "", ParaFormat::default()) else { continue };
|
||||
if let Some(it) = self.item_mut(fid)
|
||||
&& let Some(tf) = it.text_frame_mut()
|
||||
{
|
||||
tf.options.columns = columns.max(1);
|
||||
tf.options.gutter = gutter;
|
||||
}
|
||||
match prev {
|
||||
Some(p) => {
|
||||
let _ = self.thread(p, fid);
|
||||
}
|
||||
None => story = Some(sid),
|
||||
}
|
||||
prev = Some(fid);
|
||||
}
|
||||
self.settings.primary_story = story;
|
||||
}
|
||||
|
||||
fn make_page(&mut self, parent: Option<SpreadId>, columns: u32, gutter: f64, margins: Margins) -> Page {
|
||||
Page {
|
||||
id: PageId(self.alloc()),
|
||||
|
||||
@@ -139,6 +139,9 @@ pub struct DocSettings {
|
||||
pub bleed_color: [u8; 3],
|
||||
pub slug_color: [u8; 3],
|
||||
pub keyboard_increment: f64,
|
||||
/// The story in the primary text frames (Smart Text Reflow adds and removes pages for it).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub primary_story: Option<StoryId>,
|
||||
/// Chapter number (Numbering & Section Options › Document Chapter Numbering).
|
||||
pub chapter_number: u32,
|
||||
}
|
||||
@@ -163,6 +166,7 @@ impl Default for DocSettings {
|
||||
bleed_color: [255, 72, 103],
|
||||
slug_color: [100, 188, 221],
|
||||
keyboard_increment: 1.0,
|
||||
primary_story: None,
|
||||
chapter_number: 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,3 +219,4 @@ pub fn file_from(b: &[u8]) -> Result<designcraft_doc::Document> {
|
||||
file::from_bytes(b)
|
||||
}
|
||||
pub use file::{base64_decode, base64_encode, from_bytes, to_bytes};
|
||||
pub(crate) use place_text::autoflow as place_text_autoflow;
|
||||
|
||||
@@ -47,7 +47,7 @@ fn fit(d: &Document, sid: StoryId) -> (Option<usize>, usize) {
|
||||
}
|
||||
|
||||
/// Thread new frames on new pages (margin rectangles) until the story fits. Returns pages added.
|
||||
fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize) -> Result<usize> {
|
||||
pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize) -> Result<usize> {
|
||||
let mut added = 0;
|
||||
loop {
|
||||
let (overset, shown) = fit(d, sid);
|
||||
@@ -254,3 +254,39 @@ mod place_pdf_tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod reflow_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn smart_text_reflow_adds_and_removes_pages() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 1, "primaryTextFrame": true})).unwrap();
|
||||
let sid = s.doc().unwrap().doc.settings.primary_story.expect("primary story").0;
|
||||
assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().frames.len(), 1);
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
|
||||
let long = "Words keep coming and the page fills up quickly with them. ".repeat(250);
|
||||
s.execute("text.insert", &json!({"text": long})).unwrap();
|
||||
let pages = s.doc().unwrap().doc.page_count();
|
||||
assert!(pages >= 3, "pages added: {pages}");
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
assert_eq!(d.story(designcraft_doc::StoryId(sid)).unwrap().frames.len(), pages);
|
||||
// One undo takes the typing and its pages back.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.page_count(), 1);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
// Delete the text: the empty pages at the end go.
|
||||
let n = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().len();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": n})).unwrap();
|
||||
s.execute("text.delete", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.page_count(), 1);
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
// Off: no pages added.
|
||||
s.execute("prefs.set", &json!({"smartTextReflow": false})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": long})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.page_count(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,9 @@ pub struct Prefs {
|
||||
pub scale_strokes: bool,
|
||||
/// X/Y/W/H in the Control and Properties panels measure the stroke's outer edge.
|
||||
pub dimensions_include_stroke: bool,
|
||||
/// Preferences › Type › Smart Text Reflow: pages follow the primary text frame's story
|
||||
/// (added while it oversets, empty ones at the end removed).
|
||||
pub smart_text_reflow: bool,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
@@ -142,6 +145,7 @@ impl Default for Prefs {
|
||||
star_inset: 0.0,
|
||||
scale_strokes: true,
|
||||
dimensions_include_stroke: true,
|
||||
smart_text_reflow: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,6 +244,9 @@ impl Session {
|
||||
let before = self.active().map(|d| (d.uid, d.doc.clone()));
|
||||
let r = (spec.run)(self, params)?;
|
||||
self.record_transform(id, params);
|
||||
if self.prefs.smart_text_reflow && spec.undoable {
|
||||
self.smart_reflow();
|
||||
}
|
||||
// Record undo if the document changed (and we're not previewing an interaction).
|
||||
if let (Some((uid, old)), Some(st)) = (before, self.active_mut())
|
||||
&& st.uid == uid
|
||||
@@ -256,6 +263,51 @@ impl Session {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Smart Text Reflow for the primary story: add threaded pages while it oversets; remove
|
||||
/// trailing pages whose only object is an empty frame of it.
|
||||
fn smart_reflow(&mut self) {
|
||||
let Some(st) = self.active() else { return };
|
||||
if st.interaction.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(sid) = st.doc.settings.primary_story else { return };
|
||||
let Some(story) = st.doc.story(sid) else { return };
|
||||
let cs = self.cache.get(&st.doc, sid, None);
|
||||
let overset = cs.overset_at.is_some();
|
||||
let empty_tail = story.frames.len() > 1 && story.frames.last().is_some_and(|f| cs.frame(*f).is_none_or(|ft| ft.range.is_empty()));
|
||||
if !overset && !empty_tail {
|
||||
return;
|
||||
}
|
||||
let mut d = (*st.doc).clone();
|
||||
if overset {
|
||||
let _ = cmd::place_text_autoflow(&mut d, sid, 500);
|
||||
} else {
|
||||
// Drop empty trailing frames on the last pages (one frame, nothing else there).
|
||||
while let Some(story) = d.story(sid) {
|
||||
if story.frames.len() < 2 {
|
||||
break;
|
||||
}
|
||||
let last = *story.frames.last().expect("frames");
|
||||
let cs = designcraft_compose::compose_story(&d, sid, &Default::default());
|
||||
if cs.frame(last).is_some_and(|ft| !ft.range.is_empty()) {
|
||||
break;
|
||||
}
|
||||
let Some(page) = d.page_of_item(last) else { break };
|
||||
let alone = d.spreads.iter().flat_map(|sp| sp.items.iter()).filter(|i| d.page_of_item(i.id) == Some(page)).count() == 1;
|
||||
if page + 1 != d.page_count() || !alone || d.page_count() < 2 {
|
||||
break;
|
||||
}
|
||||
if d.remove_item(last).is_err() || d.delete_pages(&[page]).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(st) = self.active_mut() {
|
||||
st.doc = Arc::new(d);
|
||||
st.revision += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Remember a move/rotate/scale/shear/flip for Transform Again.
|
||||
fn record_transform(&mut self, id: &str, params: &Value) {
|
||||
if !matches!(id, "transform.move" | "transform.rotate" | "transform.scale" | "transform.shear" | "transform.flip") {
|
||||
|
||||
@@ -234,6 +234,9 @@ fn preferences(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
"type" => {
|
||||
ui.label(egui::RichText::new("Type Options").font(semibold(12.0)));
|
||||
check(ui, d, "typographersQuotes", "Use Typographer's Quotes");
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new("Smart Text Reflow").font(semibold(12.0)));
|
||||
check(ui, d, "smartTextReflow", "Add and remove pages as the primary text frame's story grows and shrinks");
|
||||
}
|
||||
"interface" => {
|
||||
ui.label(egui::RichText::new("UI Scaling").font(semibold(12.0)));
|
||||
@@ -703,7 +706,7 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
|
||||
"preferences" => {
|
||||
app.run(
|
||||
"prefs.set",
|
||||
json!({"scaleStrokes": d.b("scaleStrokes"), "dimensionsIncludeStroke": d.b("dimensionsIncludeStroke"), "typographersQuotes": d.b("typographersQuotes")}),
|
||||
json!({"scaleStrokes": d.b("scaleStrokes"), "dimensionsIncludeStroke": d.b("dimensionsIncludeStroke"), "typographersQuotes": d.b("typographersQuotes"), "smartTextReflow": d.b("smartTextReflow")}),
|
||||
)?;
|
||||
let view = match d.s("displayQuality").as_str() {
|
||||
"fast" => "view.fastDisplay",
|
||||
|
||||
+3
-3
@@ -10,13 +10,13 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
|
||||
| Priority | Features | Done | Partial | Missing | Parity |
|
||||
|---|---|---|---|---|---|
|
||||
| P0 | 102 | 99 | 3 | 0 | 99% |
|
||||
| P1 | 97 | 50 | 23 | 24 | 63% |
|
||||
| P1 | 97 | 51 | 23 | 23 | 64% |
|
||||
| P2 | 76 | 8 | 7 | 61 | 15% |
|
||||
|
||||
| Area | Parity |
|
||||
|---|---|
|
||||
| Application shell & workspace | 74% |
|
||||
| Documents, pages, spreads | 71% |
|
||||
| Documents, pages, spreads | 74% |
|
||||
| Layers | 95% |
|
||||
| Frames, shapes & paths | 85% |
|
||||
| Transform | 93% |
|
||||
@@ -70,7 +70,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
|
||||
| Documents, pages, spreads | Pasteboard | P0 | D |
|
||||
| Documents, pages, spreads | Sections & numbering | P0 | D |
|
||||
| Documents, pages, spreads | Page numbering display | P1 | P |
|
||||
| Documents, pages, spreads | Smart Text Reflow | P1 | M |
|
||||
| Documents, pages, spreads | Smart Text Reflow | P1 | D |
|
||||
| Documents, pages, spreads | Primary text frame | P1 | P |
|
||||
| Documents, pages, spreads | Parent pages | P0 | D |
|
||||
| Documents, pages, spreads | Parent item overrides | P0 | D |
|
||||
|
||||
Reference in New Issue
Block a user