A: Primary Text Frame and Smart Text Reflow

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 16:20:16 -04:00
co-authored by Claude Opus 5.5
parent 20ef6c2c4e
commit 8bfbbe7187
8 changed files with 132 additions and 6 deletions
+1
View File
@@ -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
View File
@@ -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()),
+4
View File
@@ -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,
}
}
+1
View File
@@ -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;
+37 -1
View File
@@ -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);
}
}
+52
View File
@@ -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") {
+4 -1
View File
@@ -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
View File
@@ -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 |