mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 04:36:47 -06:00
Conflicts resolved per file (both sides kept unless noted):
crates/engine/src/atomic.rs | 1 | main's: `write_file` removed (main routes command writes through `crate::io::write_file`); its one caller in file.rs now uses `crate::io::write_file`
crates/engine/src/cmd/datamerge/fill.rs | 1 | next's `Graphic` literal (has `clip` and `wrap`, which next's `Graphic` carries)
crates/engine/src/cmd/datamerge/mod.rs | 1 | main's `read_path_capped` (crate::io metadata/read_capped, roots checked); next's capped read is the same behaviour
crates/engine/src/cmd/datamerge/parse.rs | 1 | main's (`crate::io::metadata`, `crate::io::Metadata`)
crates/engine/src/cmd/edit.rs | 1 | next's `spread_param(p, "edit.paste")` (the second argument is the command id on both sides)
crates/engine/src/cmd/export.rs | 3 | main's: EPUB params doc, `toc_title` once (main's position; next's duplicate dropped)
crates/engine/src/cmd/file.rs | 7 | place doc: next's (lists page/rect/autoflow params #803 checks); next's recent-file functions + main's `foreign_document`; next's open (load_bytes, warnings, non-DesignCraft refusal) with main's checked `crate::io::read_file` for the bytes; main's `check_write` be
crates/engine/src/cmd/find.rs | 7 | main's Find scope (FindContext, scope/selection/story, continuation, UTF-8 repair) on next's `designcraft_compose::grep::Grep` (dialect GREP): find uses `find_all` capped at MAX_MATCHES, change uses `replacements`/`find_all` (literal change text not expanded), fin
crates/engine/src/cmd/fonts.rs | 1 | next's (`has_style`, resolved/replacement face); `has_style` already compares styles ignoring spacing and case, which is main's `same_style` fix; main's test kept
crates/engine/src/cmd/interchange.rs | 1 | next's `import_idml_report_with_previews` (previews for unreadable links)
crates/engine/src/cmd/layout.rs | 8 | pages.insert: main's overflow-safe `after` + next's checked `parent_ref`; main's duplicateSpread doc; layout.section: next's `SectionEdit` (already keeps unnamed settings, #717) with main's `d.repaginate()` moved into `SectionEdit::apply` (remove and set paths),
crates/engine/src/cmd/links.rs | 5 | next's links.list (no doc clone); main's `text_status` via crate::io; `use std::io::Read` dropped (unused after main's read_capped); test literals main's. `link_missing` (next) uses `crate::io::exists` for the file-guard scan
crates/engine/src/cmd/object.rs | 4 | object.fill/stroke tint-alone: main's (23e1680e7; out-of-range tint is an error, which main's test asserts); object.fit doc: next's (lists `axis`)
crates/engine/src/cmd/paths.rs | 1 | next's (shatai transform on outlines)
crates/engine/src/cmd/place_text.rs | 1 | next's `add_flow_frame` (used by autoflow)
crates/engine/src/cmd/preflight.rs | 2 | next's overset/unplacedStory issue (its `overset_location` already applies the parent transform main's #738 fix adds); next's two test modules kept with main's
crates/engine/src/cmd/scripts.rs | 1 | combined: next's `in_undo_step = false` (#406) + main's full rollback of a failed script (#709)
crates/engine/src/cmd/style.rs | 14 | param docs next's (gradient swatch.create, swatch stops, model colours); create_para next's; rename_style main's structure (rename entry, then `redirect_style_refs`), redirect has next's full reference set (cells, defaults, notes, TOC) + main's never-based-on-it
crates/engine/src/cmd/text.rs | 10 | main's selectSentence/Paragraph/Story + select_around; next's selectionAttrs (mixed) + main's openTypeFeatures/type.feature; main's comments; tracked typing: next's `reserve_command_text` + main's `insert_format_at`; empty-paragraph mark formatting main's (with `
crates/engine/src/cmd/thread.rs | 1 | next's `spread_param(p, "text.thread")`
crates/engine/src/dtd.rs | 1 | next's linear, depth-bounded `expand_parameter_entities` with main's budget (input + 1 MiB, `MAX_EXPANDED_BYTES`) and message ("expand the DTD beyond"); 16 MiB constant removed; both sides' tests pass on message
crates/engine/src/guard.rs | 2 | main's whole-transaction snapshot (docs, active, journal; #742) + next's `in_undo_step` restore; tests both
crates/engine/src/lib.rs | 5 | LoadedGraphic doc next's; next's `new_document_preview`; execute: main's `discard_stale_find_scope()` before next's nested-undo run; modules both (tests_autosize, tests_file_guard, tests_params, tests_roots)
crates/engine/src/recovery.rs | 1 | next's test (superset of main's assertion)
crates/engine/src/tests.rs | 4 | moved tests deduplicated (identical on both sides); next's preflight and direct-selection/kerning tests + main's style-delete, locked-frame and baseline-fit tests
crates/engine/src/tests_idml.rs | 3 | main's order (drop cap and margins tests identical on both sides, once each)
crates/ui-egui/src/canvas.rs | 3 | next's screen_options/DisplayKey block and tests kept; two tests that auto-merge had added twice (parent_guides_on_a_page_are_not_picked_there, baseline_grid_lines_are_the_ones_on_the_page) kept once; main's `typing`/`screen_at`/`click` helpers and its tests clicks
crates/ui-egui/src/control.rs | 3 | main's `count` check and pending budget (control queue + synthetic queue); exact-budget test runs both sides' cases (click 255 on 9,492 pending; drag 9492×255)
crates/ui-egui/src/cursors.rs | 1 | main's (same Pen cursor set)
crates/ui-egui/src/dialogs.rs | 14 | dialog body (newDocument … alert match) is next's text, with main's Find/Change scope reset (`set_field`, `reset_find_scope`, `find_change_params`) and main's Create Guides / Margins and Columns arms ported in; `Dialog::new` ruby section (next) + main's `set_fi
crates/ui-egui/src/i18n.rs | 7 | ka and pl modules, languages, statics and lookups both; `category_first` covers uk, ru, pl, ka and `count_label` keeps next's Czech plurals; shared TABLE union; next's duplicate rows for Auto, Number:, Gutter:, Top:, Bottom:, Inside:, Outside:, Left:, Right: dropped
crates/ui-egui/src/i18n/ar.rs | 2 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/it.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/ja.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/pt_br.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/uk.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/icons.rs | 2 | both tests (main's typography icon, next's distribute icons)
crates/ui-egui/src/lib.rs | 11 | UiState: next's tabs_panel + main's last_color/last_gradient; `cursors` field once; next's SVG paste and paste-on-spread-in-view; next's extensionless document open; opens_as_document next's comment; tests both (next's control bar / key-name tests, main's rich black
crates/ui-egui/src/menus.rs | 15 | app.language doc next's text with pl and ka; UI_COMMANDS both (next's Tabs, Toggle Presentation, Apply None text; main's panel commands, Apply Color, Apply Gradient); Edit › Interface Language stays out (next moved it to Preferences); Type menu main's order with
crates/ui-egui/src/panels/glyphs.rs | 1 | next's (already has main's scroll-bar fix); the scroll-bar test module auto-merge had added twice kept once
crates/ui-egui/src/panels/layers.rs | 3 | next's drag-and-drop `object_rows`, with main's features ported into it: selection reveal (layer opens, row scrolls into view once), selected rows highlighted (also a group whose member is selected), double-click rename in place (`object.rename` queued like
crates/ui-egui/src/panels/mod.rs | 6 | tabs + typography modules and color_panel_tests; SelInfo has wrap_side/wrap_contour and wrap_offsets (offsets from the graphic's own wrap when it is selected); next's swatch_picker returning the chip response; next's content_selection_tests
crates/ui-egui/src/panels/pages.rs | 1 | next's (parents dragged by id)
crates/ui-egui/src/panels/properties.rs | 4 | next's swatch popup (Apply To, Tint); main's four wrap offsets then next's Wrap To / Contour Type; test fixtures main's (`..Default::default()`, no unused builder)
crates/ui-egui/src/panels/swatches.rs | 3 | main's Delete Swatch button + next's New Swatch comment; next's draggable rows; click: main's PICKED + fill/stroke in front, with next's no-apply on double-click
crates/ui-egui/src/toolbar.rs | 2 | main's Apply Color / Gradient / None wells and functions; next's apply_none doc line
crates/compose/src/lib.rs | 5 | next's line loop kept (mark_leading + last_line_mark, wrap segments/carry, grid gyoudori); main's mark-chars base_leading block (auto-merged) stays beside it. `fits`: next's #106 rule (baseline fit for horizontal frames, full descent for paths, vertical text, footnote
crates/compose/src/tests.rs | 5 | next's tests (tombstone set, sliver/one-sided/contour/clipping wrap tests, borders, lists, kenten, grid, anchored wrap, balance, shaped frames) plus main's an_empty_last_paragraph_isnt_overset; main's wrap_object_beside_one_narrow_column_leaves_the_other_alone re-ad
crates/doc/src/assets.rs | 1 | main's doc comment (same meaning)
crates/doc/src/build.rs | 3 | parent_page_for: next's parent_page_for_side (RTL-aware); single_sided_page_shows_first_parent_page test kept once, at main's position
crates/doc/src/item.rs | 1 | next's rotated_group_bounds_follow_the_children_outlines test kept
crates/doc/src/lib.rs | 1 | next's kerning_increment field kept; main's "Bulleted & Numbered" wording
crates/doc/src/page.rs | 1 | next's outer_page_indices + parent_page_for_side (integration of parent-margins with rtl-master-sides); main's parent_page_index dropped, its only caller (build.rs) uses parent_page_for_side
crates/doc/src/slice.rs | 5 | extract: next's `marked()` (get-based, fix_notes/fix_endnotes/fix_marks; main's version indexed slices and could panic); insert_story: main's take() limit on endnote/editorial payload copies with next's id comment, redundant `let ne`/`let nn` dropped; main's excess_note
crates/geom/src/corners.rs | 1 | next's box_outline_rounds_only_closed_corners test kept
crates/render/src/damage.rs | 1 | next's wrapping-anchored-object damage test kept
crates/render/src/fx.rs | 2 | feather: next's choke/corners sigma+join inside main's structure (body and clip at device resolution, with_filters(.., None)); inner glow centre: next's blend `mix` with main's clip argument (bp, xf)
crates/render/src/lib.rs | 3 | next's layer comment; next's front baseline grid draw after items; gradient match on g.kind (main's tuple scrutinee did not match the single-pattern arms)
crates/render/src/text.rs | 5 | paint_glyphs: main's paint_stroke closure structure with next's stroke overprint (overprinting helper, stroke `(TextPaint, bool)`); callers pass next's overprint_fill/overprint_stroke
crates/render/tests/placed_pdf.rs | 1 | main's `near` helper version
crates/tools/src/lib.rs | 1 | main's (PenConvert already declared below, avoids a duplicate variant)
crates/tools/src/pen.rs | 1 | main's module doc
crates/tools/src/select.rs | 1 | next's Drag::Corner variant (live corners) kept
crates/tools/src/tests.rs | 4 | next's tests (content drag, live corners, table border drag, eyedropper matchFrom, fitting handles, pen cursors once); eyedropper_ignores_inactive keeps next's samples_image check (main's exact-JSON compare lacks matchFrom) with main's `..Default::default()` Asset lit
crates/tools/src/text.rs | 2 | next's table border drag (BorderDrag, border_at) kept, then main's comment on locked text frames
crates/tools/src/xform.rs | 2 | main's explicit `Vec<&Item>` for shown children; next's eyedropper matchFrom (integration 796e9d713)
Cargo.toml | 2 | main's craft-pdf workspace dep added (craft-print stays once, in next's craft-libs block); [patch.crates-io] keeps next's winit (rust-windowing #4694 backport) and adds main's krilla patch; main's skylartaylor winit left out (see UNSURE 1)
Cargo.lock | 1 | union: next's craft-atomic and craft-cms entries plus main's craft-pdf entry; needs `cargo` normalisation
README.md | 1 | Open bullet combines INDD import via indd-utils (next) and editable PowerPoint export (main); main's paragraph break kept
contributors/people.toml | 1 | both people kept (switch87 from next, Nmarello from main)
apps/designcraft-cli/src/main.rs | 4 | next's `Export` enum dispatch kept; main's .pptx export added as `Export::Pptx` (run prints slides and warnings, script runs file.exportPptx); main's `--pdf-options` null-as-{} parse; usage lines list .pptx
apps/designcraft/src/main.rs | 3 | `startup.leave()` right after `app.ui` (main #380) and next's macOS `sync_movable` kept after the IME discard; next's Apple Events install kept; main's macOS Dock Quit `with_close_requested_on_terminate` block left out (needs the skylartaylor winit; UNSURE 1)
crates/fonts/src/fontdb.rs | 9 | FONT6 aliases (next) combined with #911 WWS names (main): ScannedFace and both CatalogEntry constructors carry `wws` and `aliases`; `canonical_family` falls back to WWS family names; `exact_style_traits` canonicalises then tries typographic, then WWS names (`named_st
crates/format/src/lib.rs | 1 | tests: both sides kept (next's r7a nullable-default tests; main's oversized ZIP entry/asset/entry-count tests)
crates/idml/src/import.rs | 11 | main's bounded `Package` reader under next's `import_idml_report_with_previews` (metadata read through `pkg.part`); next's `tab_list_repaired` kept; ruler origin: next's `settings.ruler_origin`, and main's guide import reads it (main's private `RulerOrigin` enum and
crates/idml/src/tests.rs | 6 | item-level union: all next tests plus main's new ones (ruler guides, package limits, CJK leading model, liquid page rules module, mojikumi); no test dropped
crates/mcp/src/backend.rs | 1 | test comment merged from both
crates/pdf/src/tests.rs | 1 | tests: both sides kept
crates/pdf/src/text.rs | 4 | next's (skew `tan`, cluster `start`, ruby overprint warning); main had no change here beyond base
crates/textimport/src/docx.rs | 5 | next's (ListCounter integration for list levels, page-break test position); main's side was the older form of the same code
crates/epub/src/lib.rs | 30 | non-test code is next's (StoryWriter holds img_src/images/no_alt/heading_ids; covers main's #787/#791/#782 fixes, plus #314 group transforms, anchored images in images_without_alt, endnote story skipped in the TOC); tests: next's set plus main's extra css_weight_style a
docs/control-protocol.md | 2 | next's rows (ui.paste, count 1–255, documentGrid) and wording
docs/file-format-parity.md | 1 | rows combined (next's ICC notes, main's TIFF indexed colour)
docs/gaps.md | 2 | status change notes unioned; Flex Layout removed from missing (main implemented it), next's Parent Options sentence kept
docs/localization-parity.md | 4 | Polish and Georgian rows; 5 languages beyond the twelve; file list includes cs and ka; both history rows
docs/roadmap.md | 1 | M1 next's (#186 fixed on next), M2 main's IME wording
docs/target-app-parity.md | 6 | next's status line; File formats row next's, UI/UX row main's (IME no longer failing); UI line next's plus main's Swatches detail; one Page tool line (main's fuller one); Load styles and main's fuller Pathfinder line both kept, main's duplicate short Pathfinder line r
docs/ui-parity.md | 1 | IME row main's, Font menu row next's
i18n/fr.rs: five rows dropped that main's shared table now has (Row, Open Path, Flex Layout, Current Page Number, Rulers).
732 lines
28 KiB
Rust
732 lines
28 KiB
Rust
//! Stories: the text that flows through a chain of threaded text frames.
|
||
//!
|
||
//! A story is one `String` plus formatting:
|
||
//! - `paras`: one [`ParaFormat`] per paragraph (paragraphs are separated by `\n`; the last
|
||
//! paragraph has no separator), so `paras.len() == text.matches('\n').count() + 1`;
|
||
//! - `chars`: character-format runs `(byte_len, CharFormat)` covering exactly `text.len()` bytes.
|
||
//! Runs are never empty unless the text is empty (then there is exactly one empty run that holds
|
||
//! the "typing" format).
|
||
//!
|
||
//! Special characters: `\t` tab, `\u{2028}` forced line break, [`PAGE_NUMBER`], [`SECTION_MARKER`],
|
||
//! break characters ([`COLUMN_BREAK`], [`FRAME_BREAK`], [`PAGE_BREAK`], [`ODD_PAGE_BREAK`],
|
||
//! [`EVEN_PAGE_BREAK`]; each ends its paragraph, so it is followed by `\n`, see
|
||
//! [`is_break_char`]), `\u{AD}` discretionary hyphen,
|
||
//! `\u{2011}` non-breaking hyphen, `\u{A0}` non-breaking space, [`INDENT_HERE`], [`RIGHT_INDENT_TAB`],
|
||
//! [`TABLE_ANCHOR`] (a table, see [`crate::table`]), [`crate::notes::FOOTNOTE_REF`] (a footnote),
|
||
//! [`crate::xref::ANCHOR_MARK`] / [`crate::xref::XREF_MARK`] (text anchor / cross-reference).
|
||
|
||
use std::collections::BTreeMap;
|
||
use std::sync::Arc;
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::attrs::{CharAttrs, ParaAttrs};
|
||
use crate::ids::{ItemId, StoryId};
|
||
use crate::table::Table;
|
||
|
||
pub const PAGE_NUMBER: char = '\u{E000}';
|
||
pub const SECTION_MARKER: char = '\u{E001}';
|
||
pub const COLUMN_BREAK: char = '\u{E002}';
|
||
pub const FRAME_BREAK: char = '\u{E003}';
|
||
pub const PAGE_BREAK: char = '\u{E004}';
|
||
pub const INDENT_HERE: char = '\u{E005}';
|
||
pub const RIGHT_INDENT_TAB: char = '\u{E006}';
|
||
pub const NEXT_PAGE_NUMBER: char = '\u{E007}';
|
||
pub const PREV_PAGE_NUMBER: char = '\u{E008}';
|
||
/// Anchor of a table: alone in its paragraph, whose [`ParaFormat::table`] names the table.
|
||
pub const TABLE_ANCHOR: char = '\u{E009}';
|
||
pub const FORCED_LINE_BREAK: char = '\u{2028}';
|
||
pub const ODD_PAGE_BREAK: char = '\u{E011}';
|
||
pub const EVEN_PAGE_BREAK: char = '\u{E012}';
|
||
|
||
/// A column, frame or page break character. It ends its paragraph: the story stores it as the
|
||
/// paragraph's last character, followed by the `\n` separator (also at the end of the story), and
|
||
/// the next paragraph starts in the next column, frame or page. A break inside a paragraph (from
|
||
/// an older document or a raw edit) still moves the rest of that paragraph on.
|
||
pub fn is_break_char(c: char) -> bool {
|
||
matches!(c, COLUMN_BREAK | FRAME_BREAK | PAGE_BREAK | ODD_PAGE_BREAK | EVEN_PAGE_BREAK)
|
||
}
|
||
|
||
/// `text` with a `\n` after every break character that isn't followed by one. `next` is the
|
||
/// character after the text (None at the end of the story).
|
||
pub fn end_paragraphs_at_breaks(text: &str, next: Option<char>) -> String {
|
||
let mut out = String::with_capacity(text.len() + 1);
|
||
let mut chars = text.chars().peekable();
|
||
while let Some(c) = chars.next() {
|
||
out.push(c);
|
||
if is_break_char(c) && chars.peek().copied().or(next) != Some('\n') {
|
||
out.push('\n');
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
pub const BASIC_PARAGRAPH: &str = "[Basic Paragraph]";
|
||
pub const NO_CHAR_STYLE: &str = "[None]";
|
||
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct ParaFormat {
|
||
pub style: String,
|
||
#[serde(default, skip_serializing_if = "ParaAttrs::is_empty")]
|
||
pub para: ParaAttrs,
|
||
/// Paragraph-level character overrides are stored on the char runs; this holds nothing.
|
||
#[serde(default, skip_serializing_if = "CharAttrs::is_empty")]
|
||
pub chars: CharAttrs,
|
||
/// The table anchored in this paragraph (key into [`Story::tables`]).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub table: Option<u64>,
|
||
}
|
||
|
||
impl Default for ParaFormat {
|
||
fn default() -> Self {
|
||
ParaFormat { style: BASIC_PARAGRAPH.into(), para: ParaAttrs::default(), chars: CharAttrs::default(), table: None }
|
||
}
|
||
}
|
||
|
||
/// The format a story without runs reports (a valid story always has one run).
|
||
static NO_FORMAT: std::sync::LazyLock<CharFormat> = std::sync::LazyLock::new(CharFormat::default);
|
||
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct CharFormat {
|
||
pub style: String,
|
||
#[serde(default, skip_serializing_if = "CharAttrs::is_empty")]
|
||
pub over: CharAttrs,
|
||
}
|
||
|
||
impl Default for CharFormat {
|
||
fn default() -> Self {
|
||
CharFormat { style: NO_CHAR_STYLE.into(), over: CharAttrs::default() }
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct CharRun {
|
||
pub len: usize,
|
||
#[serde(flatten)]
|
||
pub format: CharFormat,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct Story {
|
||
pub id: StoryId,
|
||
pub text: String,
|
||
pub paras: Vec<ParaFormat>,
|
||
pub chars: Vec<CharRun>,
|
||
/// Text frames in thread order.
|
||
pub frames: Vec<ItemId>,
|
||
/// Column progression, independent of paragraph bidi direction and frame threading.
|
||
#[serde(default)]
|
||
pub direction: crate::TextDirection,
|
||
/// Story direction: vertical stories set their lines top to bottom, following each other
|
||
/// right to left, in every frame of the thread.
|
||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||
pub vertical: bool,
|
||
/// Bumped on every edit (composition cache key).
|
||
#[serde(default)]
|
||
pub rev: u64,
|
||
/// Tables anchored in the story, by id (see [`crate::table`]).
|
||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||
pub tables: BTreeMap<u64, Arc<Table>>,
|
||
/// Footnotes, one per [`crate::notes::FOOTNOTE_REF`] in text order (see [`crate::notes`]).
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub notes: Vec<Arc<crate::notes::Footnote>>,
|
||
/// Endnotes, one per [`crate::endnotes::ENDNOTE_REF`] in text order.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub endnotes: Vec<Arc<crate::notes::Footnote>>,
|
||
/// Text anchors, one per [`crate::xref::ANCHOR_MARK`] in text order.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub anchors: Vec<Arc<crate::xref::TextAnchor>>,
|
||
/// Cross-references, one per [`crate::xref::XREF_MARK`] in text order.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub xrefs: Vec<Arc<crate::xref::CrossRef>>,
|
||
/// Index page references, one per [`crate::index::INDEX_MARK`] in text order.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub index_refs: Vec<Arc<crate::index::IndexRef>>,
|
||
/// Anchored objects, one per [`crate::anchored::OBJECT_MARK`] in text order.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub objects: Vec<Arc<crate::anchored::AnchoredObject>>,
|
||
/// Editorial notes, one per [`crate::endnotes::NOTE_MARK`] in text order.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub editorial: Vec<Arc<crate::endnotes::EditorialNote>>,
|
||
/// Place and Link: the parent story this one copies, and the parent's revision when copied.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub link: Option<(StoryId, u64)>,
|
||
/// File › Place with Create Links: the text file the story was placed from (Links panel).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub file_link: Option<TextFileLink>,
|
||
}
|
||
|
||
/// A linked text file: where a placed story came from, what the file held, and the Word Import
|
||
/// Options that brought it in, so Update Link can import it again the same way.
|
||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct TextFileLink {
|
||
pub path: String,
|
||
/// The file's size and FNV-1a hash (hex) when it was placed or last updated.
|
||
pub size: u64,
|
||
pub hash: String,
|
||
/// Its modification time then (seconds since 1970; 0: unknown), as Data Merge keeps for its
|
||
/// source: while size and time are unchanged, the Links panel doesn't read the file.
|
||
#[serde(default, skip_serializing_if = "is_zero_i64")]
|
||
pub mtime: i64,
|
||
/// Imported style name → document style (`styleMap`), including the names Auto Rename chose.
|
||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||
pub style_map: BTreeMap<String, String>,
|
||
/// `styleConflicts`: useExisting, redefine or autoRename.
|
||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||
pub style_conflicts: String,
|
||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||
pub remove_styles: bool,
|
||
/// Hash of the story's text and formatting right after the import: a different one means it
|
||
/// was edited in the layout (an update replaces those edits).
|
||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||
pub story_hash: String,
|
||
}
|
||
|
||
fn is_zero_i64(v: &i64) -> bool {
|
||
*v == 0
|
||
}
|
||
|
||
impl Story {
|
||
pub fn new(id: StoryId) -> Self {
|
||
Story {
|
||
id,
|
||
text: String::new(),
|
||
paras: vec![ParaFormat::default()],
|
||
chars: vec![CharRun { len: 0, format: CharFormat::default() }],
|
||
frames: vec![],
|
||
direction: crate::TextDirection::LeftToRight,
|
||
vertical: false,
|
||
rev: 0,
|
||
tables: BTreeMap::new(),
|
||
notes: Vec::new(),
|
||
endnotes: Vec::new(),
|
||
anchors: Vec::new(),
|
||
xrefs: Vec::new(),
|
||
index_refs: Vec::new(),
|
||
objects: Vec::new(),
|
||
editorial: Vec::new(),
|
||
link: None,
|
||
file_link: None,
|
||
}
|
||
}
|
||
|
||
pub fn with_text(id: StoryId, text: &str, para: ParaFormat) -> Self {
|
||
let mut s = Story::new(id);
|
||
s.paras[0] = para;
|
||
s.insert(0, text);
|
||
s
|
||
}
|
||
|
||
pub fn len(&self) -> usize {
|
||
self.text.len()
|
||
}
|
||
pub fn is_empty(&self) -> bool {
|
||
self.text.is_empty()
|
||
}
|
||
|
||
/// Byte ranges of paragraphs (without the trailing `\n`).
|
||
pub fn para_ranges(&self) -> Vec<std::ops::Range<usize>> {
|
||
let mut v = Vec::with_capacity(self.paras.len());
|
||
let mut start = 0;
|
||
for (i, c) in self.text.char_indices() {
|
||
if c == '\n' {
|
||
v.push(start..i);
|
||
start = i + 1;
|
||
}
|
||
}
|
||
v.push(start..self.text.len());
|
||
v
|
||
}
|
||
|
||
/// Index of the paragraph containing byte `pos`.
|
||
pub fn para_at(&self, pos: usize) -> usize {
|
||
self.text[..floor_char_boundary(&self.text, pos)].bytes().filter(|b| *b == b'\n').count()
|
||
}
|
||
|
||
/// Character format at byte `pos` (the run containing it; at a boundary, the preceding run —
|
||
/// the format new typing takes).
|
||
pub fn char_format_at(&self, pos: usize) -> &CharFormat {
|
||
let mut start = 0;
|
||
for r in &self.chars {
|
||
let end = start + r.len;
|
||
if pos < end || (pos == end && pos > start) {
|
||
return &r.format;
|
||
}
|
||
start = end;
|
||
}
|
||
self.chars.last().map_or(&NO_FORMAT, |r| &r.format)
|
||
}
|
||
|
||
/// Format of the character starting at byte `pos` (the run containing `pos`), unlike
|
||
/// [`Self::char_format_at`], which at a run boundary returns the format of the character
|
||
/// before the caret. Past the end, the last run's format.
|
||
pub fn char_format_of(&self, pos: usize) -> &CharFormat {
|
||
let mut start = 0usize;
|
||
for r in &self.chars {
|
||
let end = start.saturating_add(r.len);
|
||
if pos < end {
|
||
return &r.format;
|
||
}
|
||
start = end;
|
||
}
|
||
self.chars.last().map_or(&NO_FORMAT, |r| &r.format)
|
||
}
|
||
|
||
/// Iterate `(range, format)` of character runs.
|
||
pub fn runs(&self) -> impl Iterator<Item = (std::ops::Range<usize>, &CharFormat)> {
|
||
let mut start = 0;
|
||
self.chars.iter().map(move |r| {
|
||
let s = start;
|
||
start += r.len;
|
||
(s..start, &r.format)
|
||
})
|
||
}
|
||
|
||
/// The format text inserted at byte `pos` takes: the run before `pos`
|
||
/// ([`Story::char_format_at`]), except on an empty paragraph, where it is that paragraph's own
|
||
/// mark (the line's only character, so its format is the one chosen for that line).
|
||
pub fn insert_format_at(&self, pos: usize) -> &CharFormat {
|
||
let b = self.text.as_bytes();
|
||
let empty_para = b.get(pos) == Some(&b'\n') && pos.checked_sub(1).and_then(|p| b.get(p)) == Some(&b'\n');
|
||
if empty_para { self.format_after(pos) } else { self.char_format_at(pos) }
|
||
}
|
||
|
||
/// Insert `text` at byte `pos` using the format at that position ([`Story::insert_format_at`]).
|
||
pub fn insert(&mut self, pos: usize, text: &str) {
|
||
let fmt = self.insert_format_at(pos).clone();
|
||
self.insert_with(pos, text, fmt);
|
||
}
|
||
|
||
/// Insert with an explicit character format.
|
||
pub fn insert_with(&mut self, pos: usize, text: &str, fmt: CharFormat) {
|
||
self.insert_with_raw(pos, text, fmt);
|
||
self.fix_tables();
|
||
}
|
||
|
||
/// Insert without the table fix-up (callers fix up themselves).
|
||
pub(crate) fn insert_raw(&mut self, pos: usize, text: &str) {
|
||
let fmt = self.char_format_at(pos).clone();
|
||
self.insert_with_raw(pos, text, fmt);
|
||
}
|
||
|
||
/// [`Story::insert_with`] without the table fix-up.
|
||
pub(crate) fn insert_with_raw(&mut self, pos: usize, text: &str, fmt: CharFormat) {
|
||
if text.is_empty() {
|
||
return;
|
||
}
|
||
let pos = floor_char_boundary(&self.text, pos);
|
||
let refs = text.matches(crate::notes::FOOTNOTE_REF).count();
|
||
if refs > 0 {
|
||
let k = self.notes_before(pos);
|
||
self.notes_inserted(k, refs);
|
||
}
|
||
let erefs = text.matches(crate::endnotes::ENDNOTE_REF).count();
|
||
if erefs > 0 {
|
||
let k = self.endnotes_before(pos);
|
||
self.endnotes_inserted(k, erefs);
|
||
}
|
||
if text.contains([
|
||
crate::xref::ANCHOR_MARK,
|
||
crate::xref::XREF_MARK,
|
||
crate::index::INDEX_MARK,
|
||
crate::anchored::OBJECT_MARK,
|
||
crate::endnotes::NOTE_MARK,
|
||
]) {
|
||
self.marks_inserted(pos, text);
|
||
}
|
||
// Paragraph formats: each new '\n' splits the current paragraph; the new paragraphs copy it.
|
||
let pi = self.para_at(pos);
|
||
let newlines = text.bytes().filter(|b| *b == b'\n').count();
|
||
if newlines > 0 {
|
||
let f = self.paras[pi].clone();
|
||
for _ in 0..newlines {
|
||
self.paras.insert(pi + 1, f.clone());
|
||
}
|
||
}
|
||
self.text.insert_str(pos, text);
|
||
// Character runs.
|
||
self.splice_runs(pos, 0, text.len(), fmt);
|
||
self.normalize();
|
||
self.rev = self.rev.wrapping_add(1);
|
||
}
|
||
|
||
/// End the paragraph after every break character that isn't followed by a `\n`.
|
||
pub fn end_paragraphs_at_breaks(&mut self) {
|
||
let at: Vec<usize> = self
|
||
.text
|
||
.char_indices()
|
||
.filter(|&(i, c)| is_break_char(c) && !self.text[i + c.len_utf8()..].starts_with('\n'))
|
||
.map(|(i, c)| i + c.len_utf8())
|
||
.collect();
|
||
for &pos in at.iter().rev() {
|
||
let fmt = self.char_format_at(pos).clone();
|
||
self.insert_with_raw(pos, "\n", fmt);
|
||
}
|
||
if !at.is_empty() {
|
||
self.fix_tables();
|
||
}
|
||
}
|
||
|
||
/// Delete the byte range (clamped to char boundaries). Merged paragraphs keep the first one's format.
|
||
pub fn delete(&mut self, range: std::ops::Range<usize>) {
|
||
let a = floor_char_boundary(&self.text, range.start.min(self.text.len()));
|
||
let b = floor_char_boundary(&self.text, range.end.min(self.text.len())).max(a);
|
||
if a == b {
|
||
return;
|
||
}
|
||
let refs = self.text[a..b].matches(crate::notes::FOOTNOTE_REF).count();
|
||
if refs > 0 {
|
||
let k = self.notes_before(a);
|
||
self.notes.drain(k..(k + refs).min(self.notes.len()));
|
||
}
|
||
let erefs = self.text[a..b].matches(crate::endnotes::ENDNOTE_REF).count();
|
||
if erefs > 0 {
|
||
let k = self.endnotes_before(a);
|
||
self.endnotes.drain(k..(k + erefs).min(self.endnotes.len()));
|
||
}
|
||
self.marks_deleted(a, b);
|
||
let pi = self.para_at(a);
|
||
let removed = self.text[a..b].bytes().filter(|c| *c == b'\n').count();
|
||
// Merged paragraphs keep the tables whose anchors survive the deletion (the fix-up gives
|
||
// each its own paragraph again).
|
||
let mut claims = Vec::new();
|
||
if removed > 0 && !self.tables.is_empty() {
|
||
let ranges = self.para_ranges();
|
||
let anchor = |pi: usize, r: std::ops::Range<usize>| {
|
||
self.paras[pi].table.and_then(|id| self.text[r.clone()].find(TABLE_ANCHOR).map(|k| (r.start + k, id)))
|
||
};
|
||
let first = anchor(pi, ranges[pi].start..a);
|
||
let end = ranges[pi + removed].end;
|
||
let last = anchor(pi + removed, b.min(end)..end).map(|(at, id)| (at.saturating_sub(b - a), id));
|
||
self.paras[pi].table = first.or(last).map(|(_, id)| id);
|
||
claims.extend(first.into_iter().chain(last));
|
||
}
|
||
self.paras.drain(pi + 1..pi + 1 + removed);
|
||
self.text.replace_range(a..b, "");
|
||
self.remove_run_bytes(a, b - a);
|
||
self.normalize();
|
||
self.fix_tables_with(&claims);
|
||
self.rev = self.rev.wrapping_add(1);
|
||
}
|
||
|
||
/// Replace a range with text (delete + insert using the format at the start of the range).
|
||
pub fn replace(&mut self, range: std::ops::Range<usize>, text: &str) {
|
||
let fmt = if range.start < range.end { self.format_after(range.start).clone() } else { self.insert_format_at(range.start).clone() };
|
||
self.delete(range.clone());
|
||
self.insert_with(range.start, text, fmt);
|
||
}
|
||
|
||
/// Format of the character *starting* at `pos`.
|
||
pub fn format_after(&self, pos: usize) -> &CharFormat {
|
||
let mut start = 0;
|
||
for r in &self.chars {
|
||
if pos < start + r.len {
|
||
return &r.format;
|
||
}
|
||
start += r.len;
|
||
}
|
||
self.chars.last().map_or(&NO_FORMAT, |r| &r.format)
|
||
}
|
||
|
||
/// Apply `f` to the character format of every run intersecting `range` (splitting runs at the
|
||
/// range boundaries). With an empty range nothing changes (callers set typing formats instead).
|
||
pub fn format_chars(&mut self, range: std::ops::Range<usize>, mut f: impl FnMut(&mut CharFormat)) {
|
||
let a = floor_char_boundary(&self.text, range.start);
|
||
let b = floor_char_boundary(&self.text, range.end);
|
||
if a >= b {
|
||
return;
|
||
}
|
||
self.split_at(a);
|
||
self.split_at(b);
|
||
let mut start = 0;
|
||
for r in &mut self.chars {
|
||
let end = start + r.len;
|
||
if start >= a && end <= b && r.len > 0 {
|
||
f(&mut r.format);
|
||
}
|
||
start = end;
|
||
}
|
||
self.normalize();
|
||
self.rev = self.rev.wrapping_add(1);
|
||
}
|
||
|
||
/// Apply `f` to every paragraph intersecting `range` (an empty range = the paragraph at it).
|
||
pub fn format_paras(&mut self, range: std::ops::Range<usize>, mut f: impl FnMut(&mut ParaFormat)) {
|
||
let a = self.para_at(range.start);
|
||
let b = self.para_at(range.end.max(range.start));
|
||
for p in &mut self.paras[a..=b] {
|
||
let table = p.table;
|
||
f(p);
|
||
p.table = table;
|
||
}
|
||
self.rev = self.rev.wrapping_add(1);
|
||
}
|
||
|
||
/// Plain text of a range.
|
||
pub fn slice(&self, range: std::ops::Range<usize>) -> &str {
|
||
let a = floor_char_boundary(&self.text, range.start.min(self.text.len()));
|
||
let b = floor_char_boundary(&self.text, range.end.min(self.text.len())).max(a);
|
||
&self.text[a..b]
|
||
}
|
||
|
||
/// Check invariants (tests and debug assertions).
|
||
pub fn check(&self) -> Result<(), String> {
|
||
let n = self.text.matches('\n').count() + 1;
|
||
if self.paras.len() != n {
|
||
return Err(format!("story {}: {} paragraph formats for {} paragraphs", self.id.0, self.paras.len(), n));
|
||
}
|
||
let total: usize = self.chars.iter().map(|r| r.len).sum();
|
||
if total != self.text.len() {
|
||
return Err(format!("story {}: runs cover {total} bytes, text has {}", self.id.0, self.text.len()));
|
||
}
|
||
if self.chars.is_empty() {
|
||
return Err("no char runs".into());
|
||
}
|
||
if !self.text.is_empty() && self.chars.iter().any(|r| r.len == 0) {
|
||
return Err("empty run in non-empty story".into());
|
||
}
|
||
let mut pos = 0;
|
||
for r in &self.chars {
|
||
pos += r.len;
|
||
if !self.text.is_char_boundary(pos) {
|
||
return Err(format!("run boundary {pos} splits a character"));
|
||
}
|
||
}
|
||
self.check_tables()?;
|
||
self.check_notes()?;
|
||
self.check_endnotes()?;
|
||
self.check_marks()
|
||
}
|
||
|
||
// ---------- run helpers ----------
|
||
|
||
fn split_at(&mut self, pos: usize) {
|
||
let mut start = 0;
|
||
for i in 0..self.chars.len() {
|
||
let end = start + self.chars[i].len;
|
||
if pos > start && pos < end {
|
||
let tail = CharRun { len: end - pos, format: self.chars[i].format.clone() };
|
||
self.chars[i].len = pos - start;
|
||
self.chars.insert(i + 1, tail);
|
||
return;
|
||
}
|
||
start = end;
|
||
}
|
||
}
|
||
|
||
fn splice_runs(&mut self, pos: usize, _del: usize, ins: usize, fmt: CharFormat) {
|
||
if self.chars.len() == 1 && self.chars[0].len == 0 {
|
||
self.chars[0] = CharRun { len: ins, format: fmt };
|
||
return;
|
||
}
|
||
self.split_at(pos);
|
||
// Find insertion index: after the run that ends at pos.
|
||
let mut start = 0;
|
||
let mut idx = self.chars.len();
|
||
for (i, r) in self.chars.iter().enumerate() {
|
||
if start == pos {
|
||
idx = i;
|
||
break;
|
||
}
|
||
start += r.len;
|
||
}
|
||
// Extend the neighbouring run if it has the same format.
|
||
if idx > 0 && self.chars[idx - 1].format == fmt {
|
||
self.chars[idx - 1].len += ins;
|
||
} else if idx < self.chars.len() && self.chars[idx].format == fmt {
|
||
self.chars[idx].len += ins;
|
||
} else {
|
||
self.chars.insert(idx, CharRun { len: ins, format: fmt });
|
||
}
|
||
}
|
||
|
||
fn remove_run_bytes(&mut self, pos: usize, len: usize) {
|
||
let typing = self.char_format_at(pos).clone();
|
||
let end = pos + len;
|
||
let mut start = 0;
|
||
for r in &mut self.chars {
|
||
let (rs, re) = (start, start + r.len);
|
||
start = re;
|
||
let ov = re.min(end).saturating_sub(rs.max(pos));
|
||
r.len -= ov;
|
||
}
|
||
self.chars.retain(|r| r.len > 0);
|
||
if self.chars.is_empty() {
|
||
self.chars.push(CharRun { len: 0, format: typing });
|
||
}
|
||
}
|
||
|
||
fn normalize(&mut self) {
|
||
let mut out: Vec<CharRun> = Vec::with_capacity(self.chars.len());
|
||
for r in self.chars.drain(..) {
|
||
if r.len == 0 && !out.is_empty() {
|
||
continue;
|
||
}
|
||
match out.last_mut() {
|
||
Some(last) if last.format == r.format => last.len += r.len,
|
||
Some(last) if last.len == 0 => *last = r,
|
||
_ => out.push(r),
|
||
}
|
||
}
|
||
if out.is_empty() {
|
||
out.push(CharRun { len: 0, format: CharFormat::default() });
|
||
}
|
||
self.chars = out;
|
||
}
|
||
}
|
||
|
||
pub fn floor_char_boundary(s: &str, mut i: usize) -> usize {
|
||
i = i.min(s.len());
|
||
while !s.is_char_boundary(i) {
|
||
i -= 1;
|
||
}
|
||
i
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn st(t: &str) -> Story {
|
||
Story::with_text(StoryId(1), t, ParaFormat::default())
|
||
}
|
||
|
||
#[test]
|
||
fn paragraphs_track_newlines() {
|
||
let mut s = st("one\ntwo");
|
||
assert_eq!(s.paras.len(), 2);
|
||
s.insert(3, "\nmid");
|
||
assert_eq!(s.text, "one\nmid\ntwo");
|
||
assert_eq!(s.paras.len(), 3);
|
||
s.delete(3..8);
|
||
assert_eq!(s.text, "onetwo");
|
||
assert_eq!(s.paras.len(), 1);
|
||
s.check().unwrap();
|
||
assert_eq!(s.para_ranges(), vec![0..6]);
|
||
}
|
||
|
||
#[test]
|
||
fn char_formats_split_and_merge() {
|
||
let mut s = st("hello world");
|
||
s.format_chars(0..5, |f| f.over.size = Some(20.0));
|
||
assert_eq!(s.chars.len(), 2);
|
||
s.check().unwrap();
|
||
// Typing at the end of the bold run continues it.
|
||
s.insert(5, "!");
|
||
assert_eq!(s.chars[0].len, 6);
|
||
assert_eq!(s.text, "hello! world");
|
||
s.format_chars(0..12, |f| f.over.size = Some(20.0));
|
||
assert_eq!(s.chars.len(), 1);
|
||
s.check().unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn delete_everything_keeps_typing_format() {
|
||
let mut s = st("abc");
|
||
s.format_chars(0..3, |f| f.over.tracking = Some(50.0));
|
||
s.delete(0..3);
|
||
assert!(s.is_empty());
|
||
assert_eq!(s.chars.len(), 1);
|
||
assert_eq!(s.chars[0].format.over.tracking, Some(50.0));
|
||
s.check().unwrap();
|
||
s.insert(0, "x");
|
||
assert_eq!(s.chars[0].format.over.tracking, Some(50.0));
|
||
}
|
||
|
||
#[test]
|
||
fn unicode_boundaries_are_respected() {
|
||
let mut s = st("héllo");
|
||
s.delete(1..2); // inside 'é' (2 bytes): clamps
|
||
s.check().unwrap();
|
||
s.format_chars(0..3, |f| f.over.size = Some(9.0));
|
||
s.check().unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn saved_revision_wraps_without_stopping_edits() {
|
||
let mut s = st("seed");
|
||
s.rev = u64::MAX;
|
||
// Revisions are serialized; a valid saved story can reach the boundary.
|
||
let bytes = serde_json::to_vec(&s).unwrap();
|
||
let mut s: Story = serde_json::from_slice(&bytes).unwrap();
|
||
s.insert(s.len(), " text");
|
||
assert_eq!(s.rev, 0);
|
||
assert_eq!(s.text, "seed text");
|
||
s.rev = u64::MAX;
|
||
s.delete(4..s.len());
|
||
assert_eq!(s.rev, 0);
|
||
assert_eq!(s.text, "seed");
|
||
s.rev = u64::MAX;
|
||
s.format_chars(0..s.len(), |f| f.over.size = Some(18.0));
|
||
assert_eq!(s.rev, 0);
|
||
assert_eq!(s.char_format_at(0).over.size, Some(18.0));
|
||
s.rev = u64::MAX;
|
||
s.format_paras(0..s.len(), |p| p.style = "Heading".into());
|
||
assert_eq!(s.rev, 0);
|
||
assert_eq!(s.paras[0].style, "Heading");
|
||
s.check().unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn replace_and_format_paras() {
|
||
let mut s = st("a\nb\nc");
|
||
s.format_paras(2..2, |p| p.style = "Head".into());
|
||
assert_eq!(s.paras[1].style, "Head");
|
||
s.replace(0..1, "AAA");
|
||
assert_eq!(s.text, "AAA\nb\nc");
|
||
s.check().unwrap();
|
||
assert_eq!(s.para_at(4), 1);
|
||
}
|
||
|
||
mod prop {
|
||
use super::*;
|
||
use proptest::prelude::*;
|
||
|
||
#[derive(Debug, Clone)]
|
||
enum Op {
|
||
Ins(usize, String),
|
||
Del(usize, usize),
|
||
Fmt(usize, usize, u8),
|
||
}
|
||
|
||
fn op() -> impl Strategy<Value = Op> {
|
||
prop_oneof![
|
||
(0usize..64, "[a-zé\n ]{0,6}").prop_map(|(p, t)| Op::Ins(p, t)),
|
||
(0usize..64, 0usize..10).prop_map(|(p, n)| Op::Del(p, n)),
|
||
(0usize..64, 0usize..10, 0u8..3).prop_map(|(p, n, k)| Op::Fmt(p, n, k)),
|
||
]
|
||
}
|
||
|
||
proptest! {
|
||
#[test]
|
||
fn invariants_hold(ops in proptest::collection::vec(op(), 0..40)) {
|
||
let mut s = st("seed text\nline two");
|
||
let mut model = s.text.clone();
|
||
for o in ops {
|
||
match o {
|
||
Op::Ins(p, t) => {
|
||
let p = floor_char_boundary(&s.text, p);
|
||
s.insert(p, &t);
|
||
model.insert_str(p, &t);
|
||
}
|
||
Op::Del(p, n) => {
|
||
let a = floor_char_boundary(&s.text, p);
|
||
let b = floor_char_boundary(&s.text, a + n).max(a);
|
||
s.delete(a..b);
|
||
model.replace_range(a..b, "");
|
||
}
|
||
Op::Fmt(p, n, k) => s.format_chars(p..p + n, |f| f.over.size = Some(8.0 + k as f64)),
|
||
}
|
||
prop_assert_eq!(&s.text, &model);
|
||
prop_assert!(s.check().is_ok(), "{:?}", s.check());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|