diff --git a/DESIGN.md b/DESIGN.md index bbe9a86..35198c4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -344,6 +344,33 @@ the shape, so a label's paragraphs take clicks alone and the caret goes down on the click; Shift with the arrows selects. The arrows and the page keys step through the slides only while nothing has the keyboard. +**Text is made bold, italic, underlined or struck through**, and nothing else +is formatting a person can apply. Ctrl+B, Ctrl+I and Ctrl+U, or the row of +buttons over the page in edit mode, give the selection the mark or take it off +where all of it already has it; strikethrough has a button and no key. With a +caret and nothing selected, the mark goes to what is typed next at the caret, +and moving the caret forgets it. A button is lit where the whole selection has +its mark, and for a caret where the text typed there would. Each is one undo +step, and a mark put on with typing is the same step as the typing. A +spreadsheet cell is not formatted. + +**A mark changes spans and automatic styles and nothing else.** The range's +ends are cut into the nodes they fall inside. A span the range holds whole has +its style changed; any other run of what the range holds is wrapped in a new +span inside whatever holds it, a link included, so no link or span the +document had is taken apart. A property is written only where it changes what +is drawn: a span inside the range whose own style says otherwise stops saying +it, a span left saying nothing gives way to its contents, and taking a mark off +text it was put on gives back the paragraph it was. Bold and italic are written +for Asian and complex scripts too. A span's style is an automatic style in +`content.xml`, one the document already holds wherever one says the same, and +otherwise a new one named `T` and the first number no text style has; `Styles` +learns of each as it is written and forgets none, so an undo never frees a name +for a different style. A document with no automatic styles is given the +container before its body. `crates/odox-core/src/edit/format.rs` is the +mechanism; `tests/format.rs` formats a range of every paragraph in the corpus +and holds everything outside it equal. + **A paragraph is edited through its flat text**, built from the tree and not from the renderer's layout: a `text:s` is its spaces, a `text:tab` a tab, a `text:line-break` a newline, a span's or a link's contents the paragraph's own diff --git a/README.md b/README.md index 200776e..0f6dd4a 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,9 @@ ODF is a document they are meant to open, whichever application wrote it. Editing means changing what is there, not authoring. You can type and delete text, split a paragraph and join two, enter a value in a cell, move a shape on a -slide and resize it, undo any of that, and save. You cannot apply formatting, -insert a table or a picture, edit a formula, or find and replace. What you did +slide and resize it, make text bold, italic, underlined or struck through, +undo any of that, and save. You cannot apply other formatting, insert a table or +a picture, edit a formula, or find and replace. What you did not touch is written back as it was read, element for element, including the parts these applications have no opinion about: the document you save is the document you opened, with your change in it. @@ -96,7 +97,10 @@ as it is drawn, formatting and all. Enter starts a new paragraph, Shift+Enter breaks the line, Backspace at the start of a paragraph joins it to the one before, and the arrows, Page Up and Page Down, Ctrl+Home and Ctrl+End, Ctrl+A, Shift and the clipboard work across paragraphs the way they do in any word -processor. Undo puts the caret back where the edit was. +processor. Ctrl+B, Ctrl+I and Ctrl+U, or the buttons over the page, make the +selection bold, italic or underlined, or what you type next when nothing is +selected; strikethrough is a button. Undo puts the caret back where the edit +was. `xods` draws the sheet as a grid with the document's own column widths and cell styles, one tab per sheet, and shows the formula behind whichever cell you pick. diff --git a/crates/egui_richedit/examples/notes.rs b/crates/egui_richedit/examples/notes.rs index af34b95..11af467 100644 --- a/crates/egui_richedit/examples/notes.rs +++ b/crates/egui_richedit/examples/notes.rs @@ -65,6 +65,8 @@ impl Model for Notes { self.paragraphs.insert(at.paragraph + 1, second); Some(Position::new(at.paragraph + 1, 0)) } + // Plain notes keep no formatting. + Edit::Format { .. } => None, } } } diff --git a/crates/egui_richedit/src/editor.rs b/crates/egui_richedit/src/editor.rs index 1a4e7f5..db51c37 100644 --- a/crates/egui_richedit/src/editor.rs +++ b/crates/egui_richedit/src/editor.rs @@ -18,7 +18,7 @@ use egui::{ Response, Sense, Ui, vec2, }; -use crate::{Edit, Model, OffsetMap, Position, Selection}; +use crate::{Edit, Mark, Model, OffsetMap, Position, Selection}; /// Keys the editor keeps while it has the focus, rather than letting egui /// move the focus to another widget with them. Escape is not kept: it is how @@ -79,6 +79,16 @@ pub struct RichEdit

{ /// When the caret last moved or the text last changed, which is when its /// blink restarts. last_interaction: f64, + /// Marks given or taken off at a caret, for the text typed there next. + pending: Option>, +} + +/// Marks given or taken off with a caret and nothing selected. The next text +/// typed at that caret takes them; once the caret is anywhere else, or the +/// document has changed, they are forgotten. +struct Pending

{ + at: Position

, + marks: Vec<(Mark, bool)>, } /// What the caret is owed by the next frame that draws it. @@ -131,6 +141,7 @@ impl RichEdit

{ page_height: 0.0, dragging: false, last_interaction: 0.0, + pending: None, } } @@ -145,6 +156,7 @@ impl RichEdit

{ self.selection = Some(selection); self.group = None; self.column = None; + self.pending = None; self.owed = Owed::FocusAndReveal; } @@ -154,6 +166,7 @@ impl RichEdit

{ self.group = None; self.column = None; self.dragging = false; + self.pending = None; } /// The document was changed or replaced by something other than this @@ -162,6 +175,63 @@ impl RichEdit

{ pub fn document_replaced(&mut self) { self.group = None; self.column = None; + self.pending = None; + } + + /// Whether the selection carries a mark, as a toolbar shows it: `None` + /// where part of it does, and for a caret what text typed there would + /// take, a mark given or taken off at the caret included. `None` too when + /// there is no caret. + pub fn marked>(&self, model: &M, mark: Mark) -> Option { + let selection = self.selection.as_ref()?; + if let Some(on) = self.pending_mark(mark) { + return Some(on); + } + let (from, to) = self.ordered(selection); + model.marked(&from, &to, mark) + } + + /// Give the selection a mark, or take it off where all of it has it, in + /// one undo step; with a caret and nothing selected, give it to or take it + /// off what is typed next at the caret. Answers whether the document + /// changed. The editor takes the keyboard back from whatever asked, as a + /// toolbar's button does. + pub fn toggle>(&mut self, model: &mut M, mark: Mark) -> bool { + let Some(selection) = self.selection.clone() else { + return false; + }; + self.owed = Owed::FocusAndReveal; + let on = self.marked(model, mark) != Some(true); + if selection.is_caret() { + let at = selection.focus; + let mut marks = match self.pending.take() { + Some(pending) if pending.at == at => pending.marks, + _ => Vec::new(), + }; + marks.retain(|(m, _)| *m != mark); + if model.marked(&at, &at, mark) != Some(on) { + marks.push((mark, on)); + } + self.pending = (!marks.is_empty()).then_some(Pending { at, marks }); + return false; + } + let (from, to) = self.ordered(&selection); + self.group = None; + let changed = self.format(model, from, to, mark, on, Group::Other); + self.group = None; + changed + } + + /// What a mark given or taken off at the caret says, while the caret is + /// where it was then. + fn pending_mark(&self, mark: Mark) -> Option { + let selection = self.selection.as_ref().filter(|s| s.is_caret())?; + let pending = self.pending.as_ref().filter(|p| p.at == selection.focus)?; + pending + .marks + .iter() + .find(|(m, _)| *m == mark) + .map(|(_, on)| *on) } /// Take this frame's events and apply them to the model. Called once a @@ -322,7 +392,10 @@ impl RichEdit

{ pressed: true, modifiers, .. - } => self.key(model, *key, *modifiers), + } => match shortcut(*key).filter(|_| modifiers.command) { + Some(mark) => (false, self.toggle(model, mark)), + None => self.key(model, *key, *modifiers), + }, _ => (false, false), } } @@ -447,13 +520,31 @@ impl RichEdit

{ } } - /// Replace the selection with text, as typing does. + /// Replace the selection with text, as typing does, and give what was + /// typed the marks given or taken off at the caret. fn type_text>(&mut self, model: &mut M, text: &str) -> bool { let Some(selection) = &self.selection else { return false; }; let (from, to) = self.ordered(selection); - self.apply(model, Edit::Replace { from, to, text }, Group::Typing) + let marks: Vec<(Mark, bool)> = Mark::ALL + .into_iter() + .filter_map(|mark| self.pending_mark(mark).map(|on| (mark, on))) + .collect(); + let edit = Edit::Replace { + from: from.clone(), + to, + text, + }; + if !self.apply(model, edit, Group::Typing) { + return false; + } + if let Some(end) = self.selection.as_ref().map(|s| s.focus.clone()) { + for (mark, on) in marks { + self.format(model, from.clone(), end.clone(), mark, on, Group::Typing); + } + } + true } /// Paste text, each of its lines after the first a paragraph of its own, @@ -518,6 +609,27 @@ impl RichEdit

{ self.selection = Some(Selection::caret(at)); self.next_row = false; self.column = None; + self.pending = None; + true + } + + /// Hand a format to the model, as [`Self::apply`] does an edit, leaving + /// the selection where it is. + fn format>( + &mut self, + model: &mut M, + from: Position

, + to: Position

, + mark: Mark, + on: bool, + group: Group, + ) -> bool { + let new_step = self.group != Some(group); + let edit = Edit::Format { from, to, mark, on }; + if model.apply(edit, new_step).is_none() { + return false; + } + self.group = Some(group); true } @@ -817,6 +929,17 @@ impl RichEdit

{ } } +/// The mark a key toggles with Ctrl, or Command on a Mac. Strikethrough has +/// no shortcut that people share, so it has none. +fn shortcut(key: Key) -> Option { + match key { + Key::B => Some(Mark::Bold), + Key::I => Some(Mark::Italic), + Key::U => Some(Mark::Underline), + _ => None, + } +} + /// How many characters a paragraph holds, or `None` when it is gone. fn len(model: &M, paragraph: &M::Paragraph) -> Option { model.text(paragraph).map(|text| text.chars().count()) diff --git a/crates/egui_richedit/src/lib.rs b/crates/egui_richedit/src/lib.rs index 44ec8b3..f79edbb 100644 --- a/crates/egui_richedit/src/lib.rs +++ b/crates/egui_richedit/src/lib.rs @@ -108,6 +108,38 @@ pub enum Edit<'a, P> { /// Where the paragraph is split. at: Position

, }, + /// Give the text from one position to another a mark, or take it off. + /// The two may be in different paragraphs, `from` first in document + /// order; no paragraph is joined or split. + Format { + /// Where the formatted text begins. + from: Position

, + /// Where it ends. + to: Position

, + /// Which mark. + mark: Mark, + /// Given, or taken off. + on: bool, + }, +} + +/// Formatting a range of text is given or has taken off. The set is fixed, so +/// that neither the editor nor the model needs a style system to agree on. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Mark { + /// Bold. + Bold, + /// Italic. + Italic, + /// Underlined. + Underline, + /// Struck through. + Strike, +} + +impl Mark { + /// Every mark, in the order a toolbar shows them. + pub const ALL: [Self; 4] = [Self::Bold, Self::Italic, Self::Underline, Self::Strike]; } /// The document, as the editor sees it. The application implements this over @@ -133,9 +165,25 @@ pub trait Model { /// The last editable paragraph, `None` when there is none. fn last(&self) -> Option; + /// Whether the text from one position to another carries a mark: `Some` + /// when all of it says the same, `None` when it differs. Two positions + /// that are the same answer for the text typed there would take its + /// formatting from. A model that keeps no formatting leaves this as it + /// is, which says nothing is marked, and refuses [`Edit::Format`]. + fn marked( + &self, + from: &Position, + to: &Position, + mark: Mark, + ) -> Option { + let _ = (from, to, mark); + Some(false) + } + /// Make an edit, and answer where the caret stands after it: after the - /// replacing text, or at the start of the second half of a split. `None` - /// refuses the edit and leaves the document as it was. + /// replacing text, at the start of the second half of a split, or for a + /// format at the end of the formatted text. `None` refuses the edit and + /// leaves the document as it was. /// /// `new_step` says whether the edit begins a new undo step or continues /// the one before it: the editor groups a run of typing into one step. An diff --git a/crates/egui_richedit/tests/editing.rs b/crates/egui_richedit/tests/editing.rs index 3881f40..df3912a 100644 --- a/crates/egui_richedit/tests/editing.rs +++ b/crates/egui_richedit/tests/editing.rs @@ -2,7 +2,8 @@ //! raw input, and what the model holds afterwards is what is checked. //! //! The model is a list of plain strings, which is the point: nothing about the -//! editor may need a richer document than that to work. +//! editor may need a richer document than that to work. Bold, one flag a +//! character, is the one formatting it keeps, for the marks. // // Author: David M. Anderson // Built with AI assistance (Claude, Anthropic) @@ -10,11 +11,13 @@ use egui::output::OutputCommand; use egui::text::LayoutJob; use egui::{Context, Event, Id, Key, Modifiers, Pos2, RawInput, Rect, Sense, TextFormat, vec2}; -use egui_richedit::{Edit, Laid, Model, ParagraphJob, Position, RichEdit, Selection}; +use egui_richedit::{Edit, Laid, Mark, Model, ParagraphJob, Position, RichEdit, Selection}; /// Paragraphs of plain text, named by their index. struct Plain { paragraphs: Vec, + /// Whether each character of each paragraph is bold. + bold: Vec>, /// How many undo steps the edits made so far began. steps: usize, } @@ -23,9 +26,43 @@ impl Plain { fn new(paragraphs: &[&str]) -> Self { Self { paragraphs: paragraphs.iter().map(|&p| p.to_owned()).collect(), + bold: paragraphs + .iter() + .map(|p| vec![false; p.chars().count()]) + .collect(), steps: 0, } } + + /// The bold characters of a paragraph. + fn bold_text(&self, paragraph: usize) -> String { + self.paragraphs[paragraph] + .chars() + .zip(&self.bold[paragraph]) + .filter(|(_, bold)| **bold) + .map(|(c, _)| c) + .collect() + } + + /// Each paragraph from one position to another, with the range of it + /// between them. + fn spans( + &self, + from: &Position, + to: &Position, + ) -> Vec<(usize, std::ops::Range)> { + (from.paragraph..=to.paragraph) + .map(|p| { + let start = if p == from.paragraph { from.offset } else { 0 }; + let end = if p == to.paragraph { + to.offset + } else { + self.bold[p].len() + }; + (p, start..end) + }) + .collect() + } } fn byte(text: &str, offset: usize) -> usize { @@ -57,6 +94,24 @@ impl Model for Plain { self.paragraphs.len().checked_sub(1) } + fn marked(&self, from: &Position, to: &Position, mark: Mark) -> Option { + if mark != Mark::Bold { + return Some(false); + } + if from == to { + // Typed text takes the character before it. + let bold = &self.bold[from.paragraph]; + let at = from.offset.saturating_sub(1); + return Some(bold.get(at).copied().unwrap_or(false)); + } + let mut chars = self + .spans(from, to) + .into_iter() + .flat_map(|(p, range)| self.bold[p][range].to_vec()); + let first = chars.next()?; + chars.all(|b| b == first).then_some(first) + } + fn apply(&mut self, edit: Edit<'_, usize>, new_step: bool) -> Option> { if new_step { self.steps += 1; @@ -67,11 +122,18 @@ impl Model for Plain { let last = self.paragraphs.get(to.paragraph)?; last[byte(last, to.offset)..].to_owned() }; + let tail_bold = self.bold[to.paragraph][to.offset..].to_vec(); + let bold = &mut self.bold[from.paragraph]; + let typed_bold = from.offset > 0 && bold[from.offset - 1]; + bold.truncate(from.offset); + bold.extend(std::iter::repeat_n(typed_bold, text.chars().count())); + bold.extend(tail_bold); let first = self.paragraphs.get_mut(from.paragraph)?; first.truncate(byte(first, from.offset)); first.push_str(text); first.push_str(&tail); self.paragraphs.drain(from.paragraph + 1..=to.paragraph); + self.bold.drain(from.paragraph + 1..=to.paragraph); Some(Position::new( from.paragraph, from.offset + text.chars().count(), @@ -81,8 +143,18 @@ impl Model for Plain { let paragraph = self.paragraphs.get_mut(at.paragraph)?; let second = paragraph.split_off(byte(paragraph, at.offset)); self.paragraphs.insert(at.paragraph + 1, second); + let second = self.bold[at.paragraph].split_off(at.offset); + self.bold.insert(at.paragraph + 1, second); Some(Position::new(at.paragraph + 1, 0)) } + Edit::Format { from, to, mark, on } => { + if mark == Mark::Bold { + for (p, range) in self.spans(&from, &to) { + self.bold[p][range].fill(on); + } + } + Some(to) + } } } } @@ -362,6 +434,10 @@ impl Model for Refusing { self.0.last() } + fn marked(&self, from: &Position, to: &Position, mark: Mark) -> Option { + self.0.marked(from, to, mark) + } + fn apply(&mut self, edit: Edit<'_, usize>, new_step: bool) -> Option> { match &edit { Edit::Replace { from, to, .. } if from.paragraph != to.paragraph => None, @@ -565,3 +641,71 @@ fn a_paragraph_that_leaves_drags_takes_a_caret_on_a_click_and_not_on_a_drag() { let clicked = frame(vec![button(5.0, false)]).expect("a click puts a caret down"); assert_eq!(clicked.focus.paragraph, 0); } + +fn command(key: Key) -> (Key, Modifiers) { + (key, Modifiers::COMMAND) +} + +#[test] +fn a_mark_on_a_selection_is_one_step_and_keeps_the_selection() { + let mut h = Harness::new(&["one two", "three"]); + h.caret(0, 4); + // "two", the break, and "th". + let mut keys = vec![(Key::ArrowRight, Modifiers::SHIFT); 6]; + keys.push(command(Key::B)); + h.keys(&keys); + assert_eq!(h.model.bold_text(0), "two"); + assert_eq!(h.model.bold_text(1), "th"); + assert_eq!(h.model.steps, 1); + let selection = h.editor.selection().expect("a selection").clone(); + assert!(!selection.is_caret(), "the selection stays"); + assert_eq!(h.editor.marked(&h.model, Mark::Bold), Some(true)); + + // All of it bold: the mark comes off. + h.keys(&[command(Key::B)]); + assert_eq!(h.model.bold_text(0), ""); + assert_eq!(h.model.steps, 2); +} + +#[test] +fn a_mixed_selection_takes_the_mark_throughout() { + let mut h = Harness::new(&["abcd"]); + h.caret(0, 0); + h.keys(&[(Key::ArrowRight, Modifiers::SHIFT), command(Key::B)]); + assert_eq!(h.model.bold_text(0), "a"); + h.caret(0, 0); + h.keys(&[(Key::End, Modifiers::SHIFT)]); + assert_eq!(h.editor.marked(&h.model, Mark::Bold), None); + h.keys(&[command(Key::B)]); + assert_eq!(h.model.bold_text(0), "abcd"); +} + +#[test] +fn a_mark_at_a_caret_goes_to_what_is_typed_there_next() { + let mut h = Harness::new(&["ab"]); + h.caret(0, 1); + h.keys(&[command(Key::B)]); + assert_eq!(h.model.steps, 0, "nothing changed yet"); + assert_eq!(h.editor.marked(&h.model, Mark::Bold), Some(true)); + h.typed("x"); + h.typed("y"); + assert_eq!(h.model.paragraphs, ["axyb"]); + assert_eq!(h.model.bold_text(0), "xy"); + assert_eq!(h.model.steps, 1, "the typing and its mark are one step"); +} + +#[test] +fn a_mark_at_a_caret_is_forgotten_when_the_caret_moves_or_is_toggled_back() { + let mut h = Harness::new(&["abc"]); + h.caret(0, 1); + h.keys(&[command(Key::B)]); + h.key(Key::ArrowRight); + assert_eq!(h.editor.marked(&h.model, Mark::Bold), Some(false)); + h.typed("x"); + assert_eq!(h.model.bold_text(0), ""); + + h.keys(&[command(Key::B), command(Key::B)]); + assert_eq!(h.editor.marked(&h.model, Mark::Bold), Some(false)); + h.typed("y"); + assert_eq!(h.model.bold_text(0), ""); +} diff --git a/crates/odox-core/src/doc/mod.rs b/crates/odox-core/src/doc/mod.rs index e952a39..8314f24 100644 --- a/crates/odox-core/src/doc/mod.rs +++ b/crates/odox-core/src/doc/mod.rs @@ -89,27 +89,13 @@ impl Document { /// declares for the namespace on its content root, or the conventional one /// where it declares none. pub fn name(&self, ns: &Ns, local: &str) -> Name { - let declared = self - .content - .attrs - .iter() - .filter(|a| a.name.ns == Ns::Xmlns) - .find(|a| Ns::from_uri(&a.value) == *ns) - .map(|a| &*a.name.local); - Name::new( - declared.unwrap_or(ns.conventional_prefix()), - local, - ns.clone(), - ) + self.content.name_for(ns, local) } /// Whether the content root declares a namespace, which is what decides /// whether an attribute in it may be written at all. pub fn declares(&self, ns: &Ns) -> bool { - self.content - .attrs - .iter() - .any(|a| a.name.ns == Ns::Xmlns && Ns::from_uri(&a.value) == *ns) + self.content.declares(ns) } /// The version of the format the document declares, as `office:version`. diff --git a/crates/odox-core/src/edit.rs b/crates/odox-core/src/edit.rs index 42d9e8f..67a4c0d 100644 --- a/crates/odox-core/src/edit.rs +++ b/crates/odox-core/src/edit.rs @@ -17,11 +17,15 @@ // Author: David M. Anderson // Built with AI assistance (Claude, Anthropic) +mod format; + use std::fmt; use std::ops::Range; use crate::xml::{Attribute, Element, Name, Node, Ns}; +pub use format::{Mark, format, marked}; + /// Why an edit was not made. Each is a state of the document rather than a /// failure, and the window says which. #[derive(Debug, Clone, PartialEq, Eq)] @@ -30,6 +34,8 @@ pub enum Refused { Covered, /// The cell holds a formula, which this version does not edit. Formula, + /// The document does not declare a namespace the edit would write in. + Namespace, /// There is no such sheet, slide, shape or paragraph. NotFound, /// An end of the range is inside a table, a cell or a frame the range @@ -42,6 +48,7 @@ impl fmt::Display for Refused { match self { Self::Covered => write!(f, "the cell is covered by a neighbour's span"), Self::Formula => write!(f, "the cell holds a formula"), + Self::Namespace => write!(f, "the document does not declare the namespace needed"), Self::NotFound => write!(f, "nothing is there to edit"), Self::Structure => write!(f, "the range crosses a table or a frame"), } diff --git a/crates/odox-core/src/edit/format.rs b/crates/odox-core/src/edit/format.rs new file mode 100644 index 0000000..dbbf0fd --- /dev/null +++ b/crates/odox-core/src/edit/format.rs @@ -0,0 +1,645 @@ +//! Bold, italic, underline and strikethrough over a range of a paragraph's +//! text. DESIGN.md §11. +//! +//! The range's ends are cut into the nodes they fall inside, and what lies +//! between is given the mark in one of two ways: a span the range holds whole +//! has its style changed, and any other run of nodes is wrapped in a new span, +//! inside whatever holds it, whose style says only the one thing. The spans +//! the document already had are never taken apart, and a property is written +//! only where it changes what is drawn, so that taking a mark off the text it +//! was put on gives back the paragraph it was. +//! +//! A span's style is an automatic style in `content.xml`. One the document +//! already holds is used again where it says the same thing; otherwise one is +//! written, named `T` and the first number no text style has. +// +// Author: David M. Anderson +// Built with AI assistance (Claude, Anthropic) + +use std::collections::HashSet; +use std::ops::Range; + +use super::{ + Kind, Refused, is_inline_container, is_paragraph, normalize, parent_of, segments, set_count, + text, +}; +use crate::style::{Family, Styles, TextProperties}; +use crate::xml::{Attribute, Element, Name, Node, Ns}; + +/// Formatting a range of text is given or has taken off. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Mark { + /// Bold. + Bold, + /// Italic. + Italic, + /// Underlined. + Underline, + /// Struck through. + Strike, +} + +impl Mark { + fn of(self, properties: &TextProperties) -> Option { + match self { + Self::Bold => properties.bold, + Self::Italic => properties.italic, + Self::Underline => properties.underline, + Self::Strike => properties.strike, + } + } + + /// Every attribute of `style:text-properties` that says something about + /// the mark: what is cleared before the mark is written. + fn attributes(self) -> Vec<(Ns, &'static str)> { + let style = |names: &[&'static str]| names.iter().map(|n| (Ns::Style, *n)).collect(); + match self { + Self::Bold => vec![ + (Ns::Fo, "font-weight"), + (Ns::Style, "font-weight-asian"), + (Ns::Style, "font-weight-complex"), + ], + Self::Italic => vec![ + (Ns::Fo, "font-style"), + (Ns::Style, "font-style-asian"), + (Ns::Style, "font-style-complex"), + ], + Self::Underline => style(&[ + "text-underline-type", + "text-underline-style", + "text-underline-width", + "text-underline-color", + "text-underline-mode", + ]), + Self::Strike => style(&[ + "text-line-through-type", + "text-line-through-style", + "text-line-through-width", + "text-line-through-color", + "text-line-through-text", + "text-line-through-text-style", + "text-line-through-mode", + ]), + } + } + + /// What is written to give the mark or take it off. Bold and italic are + /// written for Asian and complex scripts too, or text in those scripts + /// would not change in an application that reads them. + fn written(self, on: bool) -> Vec<(Ns, &'static str, &'static str)> { + match self { + Self::Bold => { + let weight = if on { "bold" } else { "normal" }; + vec![ + (Ns::Fo, "font-weight", weight), + (Ns::Style, "font-weight-asian", weight), + (Ns::Style, "font-weight-complex", weight), + ] + } + Self::Italic => { + let style = if on { "italic" } else { "normal" }; + vec![ + (Ns::Fo, "font-style", style), + (Ns::Style, "font-style-asian", style), + (Ns::Style, "font-style-complex", style), + ] + } + Self::Underline if on => vec![ + (Ns::Style, "text-underline-style", "solid"), + (Ns::Style, "text-underline-width", "auto"), + (Ns::Style, "text-underline-color", "font-color"), + ], + Self::Underline => vec![(Ns::Style, "text-underline-style", "none")], + Self::Strike => vec![( + Ns::Style, + "text-line-through-style", + if on { "solid" } else { "none" }, + )], + } + } +} + +/// Whether the characters of a range carry a mark: `Some` when all of them +/// say the same, `None` when they differ. A range of no characters answers +/// for the character before it, or after it at the paragraph's start, which +/// is the one text typed there takes its formatting from; a paragraph with no +/// characters answers for itself. +pub fn marked( + paragraph: &Element, + range: Range, + mark: Mark, + styles: &Styles, +) -> Option { + let base = paragraph_mark(paragraph, mark, styles); + let (start, end) = if range.start < range.end { + (range.start, range.end) + } else if range.start > 0 { + (range.start - 1, range.start) + } else { + (0, 1) + }; + let mut answer = None; + for segment in segments(paragraph) { + let segment_end = segment.start + segment.kind.len(); + if segment.kind.len() == 0 || segment_end <= start || end <= segment.start { + continue; + } + let value = mark_at(paragraph, &segment.path, base, mark, styles); + if answer.is_some_and(|a| a != value) { + return None; + } + answer = Some(value); + } + Some(answer.unwrap_or(base)) +} + +/// Give a range of the text of the paragraph at a path under the content root +/// a mark, or take it off, writing into the root's automatic styles whatever +/// style that needs and telling `styles` of it. +/// +/// A document with no `office:automatic-styles` is given one before its body, +/// as the format orders them, which moves the body one place along: a path +/// held from before the edit is to be taken again. +/// +/// # Errors +/// +/// The path does not lead to a paragraph, or the content root does not +/// declare a namespace the mark is written in. Each is refused before +/// anything changes. +pub fn format( + content: &mut Element, + path: &[usize], + range: Range, + mark: Mark, + on: bool, + styles: &mut Styles, +) -> Result<(), Refused> { + let paragraph = content + .at(path) + .filter(|e| is_paragraph(e)) + .ok_or(Refused::NotFound)?; + let needs_fo = matches!(mark, Mark::Bold | Mark::Italic); + if !content.declares(&Ns::Style) || needs_fo && !content.declares(&Ns::Fo) { + return Err(Refused::Namespace); + } + let len = text(paragraph).chars().count(); + let end = range.end.min(len); + let start = range.start.min(end); + if start == end { + return Ok(()); + } + + let mut edited = paragraph.clone(); + cut_at(&mut edited, end); + cut_at(&mut edited, start); + // What the range holds: every character inside it, and every element of + // no length strictly inside it. One at either end stays outside a span. + let selected = segments(&edited) + .into_iter() + .filter(|s| match s.kind.len() { + 0 => start < s.start && s.start < end, + n => start <= s.start && s.start + n <= end, + }) + .map(|s| s.path) + .collect(); + let base = paragraph_mark(&edited, mark, styles); + let root = Element { + attrs: content.attrs.clone(), + ..element(content.name.clone()) + }; + let mut formatter = Formatter { + mark, + on, + selected, + automatic: automatic_text_styles(content), + text: edited.name.prefix.as_deref().unwrap_or("text").to_owned(), + root, + styles, + minted: Vec::new(), + }; + formatter.within(&mut edited, &mut Vec::new(), base); + normalize(&mut edited); + let minted = formatter.minted; + + // The paragraph first: writing a new automatic-styles container would + // move the body, and the path with it. + *content.at_mut(path).ok_or(Refused::NotFound)? = edited; + if !minted.is_empty() { + let container = automatic_styles(content); + container + .children + .extend(minted.into_iter().map(Node::Element)); + container.self_closing = false; + } + Ok(()) +} + +/// The name a paragraph, a span or a link is resolved by, as the renderer +/// resolves it. +fn style_name(element: &Element) -> &str { + element.attr(&Ns::Text, "style-name").unwrap_or("Standard") +} + +/// What the paragraph's own style says, which everything in it reads unless +/// a span says otherwise. +fn paragraph_mark(paragraph: &Element, mark: Mark, styles: &Styles) -> bool { + mark.of(&styles + .resolve(&Family::Paragraph, style_name(paragraph)) + .text) + .unwrap_or(false) +} + +/// What an element's own style says about the mark: a span's or a link's, and +/// nothing for anything else inside a paragraph. +fn own(element: &Element, mark: Mark, styles: &Styles) -> Option { + if element.is(&Ns::Text, "span") || element.is(&Ns::Text, "a") { + mark.of(&styles.resolve(&Family::Text, style_name(element)).text) + } else { + None + } +} + +/// Whether the node at a path under a paragraph reads the mark: the nearest +/// container above it that says so, or the paragraph. +fn mark_at(paragraph: &Element, path: &[usize], base: bool, mark: Mark, styles: &Styles) -> bool { + (1..path.len()) + .filter_map(|depth| paragraph.at(&path[..depth])) + .fold(base, |value, e| own(e, mark, styles).unwrap_or(value)) +} + +/// Cut the node whose characters surround an offset in two, so that the +/// offset falls between nodes. +fn cut_at(paragraph: &mut Element, at: usize) { + let Some(segment) = segments(paragraph) + .into_iter() + .find(|s| s.start < at && at < s.start + s.kind.len()) + else { + return; + }; + let offset = at - segment.start; + let Some((parent, index)) = parent_of(paragraph, &segment.path) else { + return; + }; + let byte = |t: &str| t.char_indices().nth(offset).map_or(t.len(), |(b, _)| b); + let second = match (&mut parent.children[index], segment.kind) { + (Node::Text(t), _) => Node::Text(t.split_off(byte(t))), + (Node::CData(t), _) => Node::CData(t.split_off(byte(t))), + (Node::Element(space), Kind::Spaces(count)) => { + let mut second = space.clone(); + set_count(space, offset); + set_count(&mut second, count - offset); + Node::Element(second) + } + _ => return, + }; + parent.children.insert(index + 1, second); +} + +/// The text styles `content.xml` holds among its automatic styles. +fn automatic_text_styles(content: &Element) -> Vec { + content + .child(&Ns::Office, "automatic-styles") + .into_iter() + .flat_map(Element::elements) + .filter(|e| e.is(&Ns::Style, "style") && e.attr(&Ns::Style, "family") == Some("text")) + .cloned() + .collect() +} + +/// The content root's `office:automatic-styles`, written before the body where +/// the document has none. +fn automatic_styles(content: &mut Element) -> &mut Element { + let found = |local: &'static str| move |n: &Node| matches!(n, Node::Element(e) if e.is(&Ns::Office, local)); + let at = if let Some(at) = content.children.iter().position(found("automatic-styles")) { + at + } else { + let before = content + .children + .iter() + .position(|n| found("master-styles")(n) || found("body")(n)) + .unwrap_or(content.children.len()); + let container = element(content.name_for(&Ns::Office, "automatic-styles")); + content.children.insert(before, Node::Element(container)); + content.self_closing = false; + before + }; + let Node::Element(container) = &mut content.children[at] else { + unreachable!("found or written as an element"); + }; + container +} + +fn element(name: Name) -> Element { + Element { + name, + attrs: Vec::new(), + children: Vec::new(), + self_closing: true, + } +} + +/// How much of a node the range holds. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Held { + /// All of it. + Whole, + /// Some of what is inside it. + Part, + /// None of it. + Outside, + /// A comment or a processing instruction, which a run passes over. + Neutral, +} + +/// One range being given a mark, or having it taken off. +struct Formatter<'a> { + mark: Mark, + on: bool, + /// The paths from the paragraph of what the range holds. + selected: HashSet>, + /// The text styles among the automatic styles, with those written here. + automatic: Vec, + /// The prefix the paragraph spells the text namespace with. + text: String, + /// The content root without its children: what a name written into the + /// styles is spelled by. + root: Element, + styles: &'a mut Styles, + /// The styles written here, to go into the tree. + minted: Vec, +} + +impl Formatter<'_> { + fn held(&self, node: &Node, path: &mut Vec) -> Held { + match node { + Node::Comment(_) | Node::ProcessingInstruction(_) => Held::Neutral, + Node::Element(e) if is_inline_container(e) => { + let (mut whole, mut some) = (true, false); + for (index, child) in e.children.iter().enumerate() { + path.push(index); + match self.held(child, path) { + Held::Whole => some = true, + Held::Part => (some, whole) = (true, false), + Held::Outside => whole = false, + Held::Neutral => {} + } + path.pop(); + } + match (some, whole) { + (true, true) => Held::Whole, + (true, false) => Held::Part, + _ => Held::Outside, + } + } + _ if self.selected.contains(path) => Held::Whole, + _ => Held::Outside, + } + } + + /// Give the mark to what the range holds under an element whose contents + /// read `context`. Runs of what it holds whole are wrapped where they read + /// otherwise; a span it holds whole whose own style says otherwise is + /// restyled; and whatever it holds part of is gone into. + fn within(&mut self, element: &mut Element, path: &mut Vec, context: bool) { + let held: Vec = (0..element.children.len()) + .map(|index| { + path.push(index); + let held = self.held(&element.children[index], path); + path.pop(); + held + }) + .collect(); + let mut out = Vec::with_capacity(element.children.len()); + let mut run = Vec::new(); + // Comments after the run so far, which join it if more follows. + let mut waiting = Vec::new(); + for (index, (node, held)) in std::mem::take(&mut element.children) + .into_iter() + .zip(held) + .enumerate() + { + path.push(index); + let contrary = matches!(&node, Node::Element(e) + if is_inline_container(e) && own(e, self.mark, self.styles) == Some(!self.on)); + match (held, node) { + (Held::Neutral, node) if !run.is_empty() => waiting.push(node), + (Held::Whole, mut node) if !contrary => { + // A container inside a run reads the mark once the run + // does; something deeper in it may still say otherwise. + if let Node::Element(e) = &mut node + && is_inline_container(e) + { + self.within(e, path, self.on); + } + run.append(&mut waiting); + run.push(node); + } + (held, node) => { + self.close(&mut run, &mut out, context); + out.append(&mut waiting); + match (held, node) { + (Held::Whole, Node::Element(mut e)) if e.is(&Ns::Text, "span") => { + self.within(&mut e, path, self.on); + if self.restyle(&mut e, context) { + out.extend(e.children); + } else { + out.push(Node::Element(e)); + } + } + // A link whose own style says otherwise: its contents + // are wrapped inside it. + (Held::Whole, Node::Element(mut e)) => { + self.within(&mut e, path, !self.on); + out.push(Node::Element(e)); + } + (Held::Part, Node::Element(mut e)) => { + let inner = own(&e, self.mark, self.styles).unwrap_or(context); + self.within(&mut e, path, inner); + out.push(Node::Element(e)); + } + (_, node) => out.push(node), + } + } + } + path.pop(); + } + self.close(&mut run, &mut out, context); + out.append(&mut waiting); + element.children = out; + } + + /// Give a run the mark where its surroundings do not: a lone span by its + /// style, anything else by a new span around it. + fn close(&mut self, run: &mut Vec, out: &mut Vec, context: bool) { + if run.is_empty() { + return; + } + if context != self.on { + if let [Node::Element(span)] = run.as_mut_slice() + && span.is(&Ns::Text, "span") + { + if own(span, self.mark, self.styles) != Some(self.on) { + // The mark is written, so the span is never left empty. + self.restyle(span, context); + } + } else { + let mut style = self.blank(None); + self.write_mark(&mut style); + let name = self.name(style); + let mut span = element(Name::new(&self.text, "span", Ns::Text)); + span.set_attr(Name::new(&self.text, "style-name", Ns::Text), name); + span.children = std::mem::take(run); + span.self_closing = false; + out.push(Node::Element(span)); + return; + } + } + out.append(run); + } + + /// Change a span's style so that the span reads the mark, in a parent + /// whose contents read `context`. Answers whether the span is left saying + /// nothing at all, and should give way to its contents. + /// + /// The mark can be said two ways: written into the span's style, or left + /// out of it where what the span inherits already says it. Left out is + /// the smaller, and is taken unless the document has a style that says it + /// the written way and none that says it the other, which is what brings + /// a span back to the style it had when a mark is put on and taken off. + fn restyle(&mut self, span: &mut Element, context: bool) -> bool { + let current = span.attr(&Ns::Text, "style-name").map(ToOwned::to_owned); + let style = match current.as_deref().and_then(|n| self.automatic(n)) { + Some(existing) => existing.clone(), + None => self.blank(current.as_deref()), + }; + let mut written = style.clone(); + self.write_mark(&mut written); + let mut bare = style; + if let Some(properties) = bare.child_mut(&Ns::Style, "text-properties") { + for (ns, local) in self.mark.attributes() { + properties.remove_attr(&ns, local); + } + } + bare.children.retain(|n| { + !matches!(n, Node::Element(e) + if e.is(&Ns::Style, "text-properties") && e.attrs.is_empty() && e.children.is_empty()) + }); + // What the span reads with no word of its own on the mark: its + // style's parent, or with no parent the family's default, which is + // what a name the document does not have resolves to. + let parent = bare.attr(&Ns::Style, "parent-style-name"); + let bare_reads = self + .mark + .of(&self + .styles + .resolve(&Family::Text, parent.unwrap_or("")) + .text) + .unwrap_or(context); + + let style = if bare_reads != self.on { + written + } else if parent.is_none() && bare.elements().next().is_none() { + span.remove_attr(&Ns::Text, "style-name"); + return span.attrs.is_empty(); + } else if self.existing(&bare).is_none() && self.existing(&written).is_some() { + written + } else { + bare + }; + let name = self.name(style); + match span + .attrs + .iter_mut() + .find(|a| a.name.is(&Ns::Text, "style-name")) + { + Some(attr) => attr.value = name, + None => span.set_attr(Name::new(&self.text, "style-name", Ns::Text), name), + } + false + } + + /// Write the mark into a style's text properties, which it is given + /// where it has none. An attribute the style has is changed where it + /// stands, and one the mark does not write is taken out. + fn write_mark(&self, style: &mut Element) { + if style.child(&Ns::Style, "text-properties").is_none() { + let properties = element(self.root.name_for(&Ns::Style, "text-properties")); + style.children.push(Node::Element(properties)); + style.self_closing = false; + } + let Some(properties) = style.child_mut(&Ns::Style, "text-properties") else { + return; + }; + let written = self.mark.written(self.on); + for (ns, local) in self.mark.attributes() { + if !written.iter().any(|(n, l, _)| *n == ns && *l == local) { + properties.remove_attr(&ns, local); + } + } + for (ns, local, value) in written { + match properties.attrs.iter_mut().find(|a| a.name.is(&ns, local)) { + Some(attr) => value.clone_into(&mut attr.value), + None => properties.set_attr(self.root.name_for(&ns, local), value), + } + } + } + + /// A text style that says nothing yet, inheriting from `parent`. + fn blank(&self, parent: Option<&str>) -> Element { + let mut style = element(self.root.name_for(&Ns::Style, "style")); + style.set_attr(self.root.name_for(&Ns::Style, "family"), "text"); + if let Some(parent) = parent { + style.set_attr(self.root.name_for(&Ns::Style, "parent-style-name"), parent); + } + style + } + + fn automatic(&self, name: &str) -> Option<&Element> { + self.automatic + .iter() + .find(|s| s.attr(&Ns::Style, "name") == Some(name)) + } + + /// The name of an automatic style that says what this one does. + fn existing(&self, style: &Element) -> Option<&str> { + let wanted = unnamed(style); + self.automatic + .iter() + .find(|s| unnamed(s) == wanted) + .and_then(|s| s.attr(&Ns::Style, "name")) + } + + /// The name of an automatic style that says what this one does, written + /// into the styles where none is there yet. + fn name(&mut self, mut style: Element) -> String { + if let Some(name) = self.existing(&style) { + return name.to_owned(); + } + let taken = + |n: &str| self.styles.style(&Family::Text, n).is_some() || self.automatic(n).is_some(); + let mut number = 1; + while taken(&format!("T{number}")) { + number += 1; + } + let name = format!("T{number}"); + style.remove_attr(&Ns::Style, "name"); + style.attrs.insert( + 0, + Attribute { + name: self.root.name_for(&Ns::Style, "name"), + value: name.clone(), + }, + ); + self.styles.add(&style); + self.automatic.push(style.clone()); + self.minted.push(style); + name + } +} + +/// A style with its name taken off, to compare with another by what it says. +fn unnamed(style: &Element) -> Element { + let mut style = style.clone(); + style.remove_attr(&Ns::Style, "name"); + style +} diff --git a/crates/odox-core/src/style.rs b/crates/odox-core/src/style.rs index c7fc0f8..8f24fcc 100644 --- a/crates/odox-core/src/style.rs +++ b/crates/odox-core/src/style.rs @@ -611,6 +611,23 @@ impl Styles { } } + /// Take in a style an edit has written into the document, so that it + /// resolves from now on. + /// + /// Nothing is ever taken out: a style an undo removed from the tree stays + /// here, which keeps its name from being given to a different style while + /// a resolution of it may still be cached. + pub fn add(&mut self, element: &Element) { + if let (Some(name), Some(family)) = ( + element.attr(&Ns::Style, "name"), + element.attr(&Ns::Style, "family"), + ) { + let key = (Family::parse(family), name.to_owned()); + self.cache.borrow_mut().remove(&key); + } + self.collect_style(element); + } + /// A style by family and name. pub fn style(&self, family: &Family, name: &str) -> Option<&Style> { self.by_name.get(&(family.clone(), name.to_owned())) diff --git a/crates/odox-core/src/xml/mod.rs b/crates/odox-core/src/xml/mod.rs index d20ad4f..a6e8c36 100644 --- a/crates/odox-core/src/xml/mod.rs +++ b/crates/odox-core/src/xml/mod.rs @@ -305,6 +305,29 @@ impl Element { Some(self.attrs.remove(at).value) } + /// A name for writing under this element, in the prefix it declares for + /// the namespace, or the conventional one where it declares none. + pub fn name_for(&self, ns: &Ns, local: &str) -> Name { + let declared = self + .attrs + .iter() + .filter(|a| a.name.ns == Ns::Xmlns) + .find(|a| Ns::from_uri(&a.value) == *ns) + .map(|a| &*a.name.local); + Name::new( + declared.unwrap_or(ns.conventional_prefix()), + local, + ns.clone(), + ) + } + + /// Whether this element declares a namespace. + pub fn declares(&self, ns: &Ns) -> bool { + self.attrs + .iter() + .any(|a| a.name.ns == Ns::Xmlns && Ns::from_uri(&a.value) == *ns) + } + /// The first child element with the given name. pub fn child(&self, ns: &Ns, local: &str) -> Option<&Element> { self.elements().find(|e| e.is(ns, local)) diff --git a/crates/odox-core/tests/format.rs b/crates/odox-core/tests/format.rs new file mode 100644 index 0000000..35a591c --- /dev/null +++ b/crates/odox-core/tests/format.rs @@ -0,0 +1,485 @@ +//! Formatting a range of a paragraph: what changes, and what does not. +//! +//! The claim is the one §3 of DESIGN.md asks of every edit: formatting a range +//! changes that paragraph and the automatic styles and nothing else, the text +//! is the same text, the document reads back, and the range then reads as +//! formatted. Taking a mark off text it was put on gives back the paragraph it +//! was. Measured over every paragraph of every corpus document, and on +//! paragraphs built here to hold what the corpus is short of. +// +// Author: David M. Anderson +// Built with AI assistance (Claude, Anthropic) + +use std::path::{Path, PathBuf}; + +use odox_core::edit::{Mark, format, marked, text}; +use odox_core::{Element, Family, Node, Ns, Package, Refused, Styles, xml}; + +fn corpus() -> Vec { + let mut documents = Vec::new(); + walk( + &Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"), + &mut documents, + ); + documents.sort(); + documents +} + +fn walk(directory: &Path, into: &mut Vec) { + let Ok(entries) = std::fs::read_dir(directory) else { + return; + }; + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + if path.is_dir() { + walk(&path, into); + } else if matches!( + path.extension().and_then(|e| e.to_str()), + Some("odt" | "ods" | "odp") + ) { + into.push(path); + } + } +} + +/// The path of every `text:p` and `text:h` under an element, wherever it sits. +fn paragraph_paths(element: &Element, path: &mut Vec, into: &mut Vec>) { + for (index, child) in element.elements_indexed() { + path.push(index); + if child.is(&Ns::Text, "p") || child.is(&Ns::Text, "h") { + into.push(path.clone()); + } + paragraph_paths(child, path, into); + path.pop(); + } +} + +/// The styles container's children, which is all a format may add to. +fn automatic_count(content: &Element) -> Option { + content + .child(&Ns::Office, "automatic-styles") + .map(|c| c.children.len()) +} + +/// The content as it would be had nothing but the paragraph at a path and +/// the automatic styles changed: the paragraph put back, and whatever was +/// added to the styles taken off. +fn restored(edited: &Element, original: &Element, path: &[usize]) -> Element { + let mut restored = edited.clone(); + let mut original_path = path.to_vec(); + if automatic_count(original).is_none() && automatic_count(edited).is_some() { + original_path[0] -= 1; + } + *restored.at_mut(path).expect("the paragraph") = + original.at(&original_path).expect("it").clone(); + match automatic_count(original) { + Some(count) => { + let container = restored + .child_mut(&Ns::Office, "automatic-styles") + .expect("kept"); + container.children.truncate(count); + } + None => restored + .children + .retain(|n| !matches!(n, Node::Element(e) if e.is(&Ns::Office, "automatic-styles"))), + } + restored +} + +fn reads_back(content: &Element) { + let written = xml::serialize(content); + let again = xml::parse(&written, "content.xml").expect("well-formed after the format"); + assert!(*content == again, "the format does not read back"); +} + +/// Whether an offset falls between two spaces, where the text may be a +/// `text:s` that a format cuts in two and taking the mark off does not join. +fn between_spaces(chars: &[char], at: usize) -> bool { + at > 0 && at < chars.len() && chars[at - 1] == ' ' && chars[at] == ' ' +} + +#[test] +fn the_corpus_formats_one_paragraph_and_nothing_else() { + let mut formatted = 0; + let mut given_back = 0; + for document in corpus() { + let bytes = std::fs::read(&document).expect("the document"); + let package = Package::read(&bytes).expect("a readable package"); + let content = package.xml("content.xml").expect("content"); + let styles_part = package.optional_xml("styles.xml").expect("styles"); + let mut paths = Vec::new(); + paragraph_paths(&content, &mut Vec::new(), &mut paths); + + for path in paths { + let paragraph = content.at(&path).expect("the paragraph"); + let chars: Vec = text(paragraph).chars().collect(); + let range = chars.len() / 3..chars.len() * 2 / 3; + if range.is_empty() { + continue; + } + let mut styles = Styles::collect(Some(&content), styles_part.as_ref()); + let before = marked(paragraph, range.clone(), Mark::Bold, &styles); + + let mut edited = content.clone(); + format( + &mut edited, + &path, + range.clone(), + Mark::Bold, + true, + &mut styles, + ) + .expect("a corpus paragraph formats"); + // Styles written into a document that had no container for them + // go before the body, and move it along. + let mut path = path.clone(); + if automatic_count(&content).is_none() && automatic_count(&edited).is_some() { + path[0] += 1; + } + let after = edited.at(&path).expect("the paragraph"); + let where_ = format!("{} at {path:?}", document.display()); + assert_eq!(text(after), text(paragraph), "{where_}"); + assert_eq!( + marked(after, range.clone(), Mark::Bold, &styles), + Some(true), + "{where_}" + ); + assert!(restored(&edited, &content, &path) == content, "{where_}"); + reads_back(&edited); + formatted += 1; + + format( + &mut edited, + &path, + range.clone(), + Mark::Bold, + false, + &mut styles, + ) + .expect("and takes the mark off"); + let after = edited.at(&path).expect("the paragraph"); + assert_eq!(text(after), text(paragraph), "{where_}"); + assert_eq!( + marked(after, range.clone(), Mark::Bold, &styles), + Some(false), + "{where_}" + ); + if before == Some(false) + && !between_spaces(&chars, range.start) + && !between_spaces(&chars, range.end) + { + assert!( + after == paragraph, + "{where_}: on and off is not the paragraph it was:\n{}\n{}", + String::from_utf8_lossy(&xml::serialize(paragraph)), + String::from_utf8_lossy(&xml::serialize(after)), + ); + given_back += 1; + } + } + } + assert!(formatted > 100, "only {formatted} paragraphs formatted"); + assert!(given_back > 50, "only {given_back} paragraphs given back"); +} + +const NAMESPACES: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#; + +/// A content root holding automatic styles and one paragraph, and the path to +/// the paragraph. +fn document(automatic: &str, paragraph: &str) -> (Element, Vec) { + let source = format!( + r#"{automatic}{paragraph}"# + ); + let content = xml::parse(source.as_bytes(), "content.xml").expect("a document"); + (content, vec![1, 0, 0]) +} + +/// A paragraph's contents, written back without the paragraph's own tags. +fn inside(content: &Element, path: &[usize]) -> String { + let written = + String::from_utf8(xml::serialize(content.at(path).expect("the paragraph"))).expect("UTF-8"); + let tag = written.find("').expect("a start tag") + 1; + let close = written.rfind("(content: &'a Element, name: &str) -> &'a Element { + content + .child(&Ns::Office, "automatic-styles") + .expect("automatic styles") + .elements() + .find(|e| e.attr(&Ns::Style, "name") == Some(name)) + .unwrap_or_else(|| panic!("no style {name}")) +} + +fn text_properties<'a>(content: &'a Element, name: &str) -> &'a Element { + style(content, name) + .child(&Ns::Style, "text-properties") + .expect("text properties") +} + +const ITALIC: &str = r#""#; +const BOLD: &str = r#""#; + +fn styles_of(content: &Element) -> Styles { + Styles::collect(Some(content), None) +} + +#[test] +fn a_word_is_wrapped_and_unwrapped() { + let (original, path) = document("", "one two three"); + let mut content = original.clone(); + let mut styles = styles_of(&content); + format(&mut content, &path, 4..7, Mark::Bold, true, &mut styles).expect("bold"); + assert_eq!( + inside(&content, &path), + r#"one two three"# + ); + let properties = text_properties(&content, "T1"); + assert_eq!(properties.attr(&Ns::Fo, "font-weight"), Some("bold")); + assert_eq!( + properties.attr(&Ns::Style, "font-weight-asian"), + Some("bold") + ); + assert_eq!( + properties.attr(&Ns::Style, "font-weight-complex"), + Some("bold") + ); + assert!(styles.resolve(&Family::Text, "T1").text.bold == Some(true)); + + format(&mut content, &path, 4..7, Mark::Bold, false, &mut styles).expect("not bold"); + assert!( + content.at(&path) == original.at(&path), + "{}", + inside(&content, &path) + ); +} + +#[test] +fn marked_answers_for_a_range_and_for_a_caret() { + let (content, path) = document( + BOLD, + r#"abcdef"#, + ); + let styles = styles_of(&content); + let p = content.at(&path).expect("the paragraph"); + assert_eq!(marked(p, 2..4, Mark::Bold, &styles), Some(true)); + assert_eq!(marked(p, 1..3, Mark::Bold, &styles), None); + assert_eq!(marked(p, 0..2, Mark::Bold, &styles), Some(false)); + // A caret reads the character before it, which is what typing there takes. + assert_eq!(marked(p, 4..4, Mark::Bold, &styles), Some(true)); + assert_eq!(marked(p, 2..2, Mark::Bold, &styles), Some(false)); + assert_eq!(marked(p, 0..0, Mark::Bold, &styles), Some(false)); + assert_eq!(marked(p, 0..6, Mark::Italic, &styles), Some(false)); +} + +#[test] +fn a_span_held_whole_is_restyled_rather_than_wrapped() { + let (mut content, path) = document( + ITALIC, + r#"abcd"#, + ); + let mut styles = styles_of(&content); + format(&mut content, &path, 1..3, Mark::Bold, true, &mut styles).expect("bold"); + assert_eq!( + inside(&content, &path), + r#"abcd"# + ); + let properties = text_properties(&content, "T2"); + assert_eq!(properties.attr(&Ns::Fo, "font-style"), Some("italic")); + assert_eq!(properties.attr(&Ns::Fo, "font-weight"), Some("bold")); + // T1 is the document's own and is not changed; something else may use it. + assert_eq!( + text_properties(&content, "T1").attr(&Ns::Fo, "font-weight"), + None + ); +} + +#[test] +fn part_of_a_bold_span_is_taken_out_inside_it() { + let (mut content, path) = document(BOLD, r#"abcd"#); + let mut styles = styles_of(&content); + format(&mut content, &path, 1..3, Mark::Bold, false, &mut styles).expect("not bold"); + assert_eq!( + inside(&content, &path), + r#"abcd"# + ); + assert_eq!( + text_properties(&content, "T2").attr(&Ns::Fo, "font-weight"), + Some("normal") + ); + let p = content.at(&path).expect("the paragraph"); + assert_eq!(marked(p, 1..3, Mark::Bold, &styles), Some(false)); + assert_eq!(marked(p, 0..1, Mark::Bold, &styles), Some(true)); +} + +#[test] +fn a_bold_span_inside_the_range_is_unbolded_where_it_stands() { + let (mut content, path) = document( + BOLD, + r#"abcdef"#, + ); + let mut styles = styles_of(&content); + format(&mut content, &path, 0..6, Mark::Bold, false, &mut styles).expect("not bold"); + assert_eq!(inside(&content, &path), "abcdef"); +} + +#[test] +fn a_run_around_a_span_that_says_otherwise_fixes_the_span() { + // Italic on over a span that says upright: the run is wrapped, and the + // span inside it stops saying upright. + let upright = r#""#; + let (mut content, path) = document( + upright, + r#"abcdef"#, + ); + let mut styles = styles_of(&content); + format(&mut content, &path, 1..5, Mark::Italic, true, &mut styles).expect("italic"); + let p = content.at(&path).expect("the paragraph"); + assert_eq!(marked(p, 1..5, Mark::Italic, &styles), Some(true)); + assert_eq!(marked(p, 0..1, Mark::Italic, &styles), Some(false)); + assert_eq!(marked(p, 5..6, Mark::Italic, &styles), Some(false)); +} + +#[test] +fn a_marker_inside_the_range_goes_in_and_one_at_an_edge_stays_out() { + let (mut content, path) = document( + "", + r#"abcdef"#, + ); + let mut styles = styles_of(&content); + format(&mut content, &path, 2..6, Mark::Bold, true, &mut styles).expect("bold"); + assert_eq!( + inside(&content, &path), + r#"abcdef"# + ); +} + +#[test] +fn a_link_is_not_split() { + let (mut content, path) = document("", r#"abcd"#); + let mut styles = styles_of(&content); + format( + &mut content, + &path, + 1..3, + Mark::Underline, + true, + &mut styles, + ) + .expect("underline"); + assert_eq!( + inside(&content, &path), + r#"abcd"# + ); + let properties = text_properties(&content, "T1"); + assert_eq!( + properties.attr(&Ns::Style, "text-underline-style"), + Some("solid") + ); +} + +#[test] +fn a_style_that_says_the_same_is_used_again() { + let (mut content, path) = document("", "one two three"); + let mut styles = styles_of(&content); + format(&mut content, &path, 0..3, Mark::Strike, true, &mut styles).expect("strike"); + format(&mut content, &path, 8..13, Mark::Strike, true, &mut styles).expect("strike"); + let container = content + .child(&Ns::Office, "automatic-styles") + .expect("styles"); + assert_eq!(container.elements().count(), 1); + assert_eq!( + inside(&content, &path), + r#"one two three"# + ); +} + +#[test] +fn a_new_style_takes_a_name_nobody_has() { + let (mut content, path) = document(ITALIC, "one two"); + let mut styles = styles_of(&content); + format(&mut content, &path, 0..3, Mark::Bold, true, &mut styles).expect("bold"); + assert_eq!( + inside(&content, &path), + r#"one two"# + ); +} + +#[test] +fn a_paragraph_that_is_bold_already_is_left_alone() { + let heavy = r#""#; + let (original, path) = document(heavy, "one two"); + let mut content = original.clone(); + let mut styles = styles_of(&content); + format(&mut content, &path, 0..3, Mark::Bold, true, &mut styles).expect("bold"); + assert!(content == original); + format(&mut content, &path, 0..3, Mark::Bold, false, &mut styles).expect("not bold"); + assert_eq!( + inside(&content, &path), + r#"one two"# + ); + assert_eq!( + text_properties(&content, "T1").attr(&Ns::Fo, "font-weight"), + Some("normal") + ); +} + +#[test] +fn an_offset_inside_a_run_of_spaces_cuts_it() { + let (mut content, path) = document("", r#"a b"#); + let mut styles = styles_of(&content); + format(&mut content, &path, 2..6, Mark::Bold, true, &mut styles).expect("bold"); + let p = content.at(&path).expect("the paragraph"); + assert_eq!(text(p), "a b"); + assert_eq!(marked(p, 2..5, Mark::Bold, &styles), Some(true)); + assert_eq!(marked(p, 1..2, Mark::Bold, &styles), Some(false)); + reads_back(&content); +} + +#[test] +fn a_document_without_automatic_styles_is_given_them_before_its_body() { + let source = format!( + r#"one"# + ); + let mut content = xml::parse(source.as_bytes(), "content.xml").expect("a document"); + let mut styles = styles_of(&content); + format( + &mut content, + &[1, 0, 0], + 0..3, + Mark::Bold, + true, + &mut styles, + ) + .expect("bold"); + let names: Vec<&str> = content.elements().map(|e| &*e.name.local).collect(); + assert_eq!(names, ["font-face-decls", "automatic-styles", "body"]); + assert_eq!( + inside(&content, &[2, 0, 0]), + r#"one"# + ); + reads_back(&content); +} + +#[test] +fn a_document_that_does_not_declare_the_namespace_is_refused() { + let source = r#"one"#; + let original = xml::parse(source.as_bytes(), "content.xml").expect("a document"); + let mut content = original.clone(); + let mut styles = styles_of(&content); + assert_eq!( + format( + &mut content, + &[0, 0, 0], + 0..3, + Mark::Bold, + true, + &mut styles + ), + Err(Refused::Namespace) + ); + assert!(content == original); +} diff --git a/crates/odox-ui/po/de.po b/crates/odox-ui/po/de.po index 4f1287a..4ef56db 100644 --- a/crates/odox-ui/po/de.po +++ b/crates/odox-ui/po/de.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: odox\n" "Report-Msgid-Bugs-To: https://github.com/excelano/odox/issues\n" -"POT-Creation-Date: 2026-09-21 16:50-0500\n" +"POT-Creation-Date: 2026-09-27 17:57-0500\n" "PO-Revision-Date: 2026-09-14 09:00-0500\n" "Last-Translator: David M. Anderson \n" "Language-Team: German\n" @@ -18,124 +18,156 @@ msgstr "" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: crates/odox-ui/src/i18n.rs:89 crates/odox-ui/src/i18n.rs:93 -#: crates/odox-ui/src/shell.rs:473 crates/odox-ui/tests/german.rs:24 +#: crates/odox-ui/src/shell.rs:499 crates/odox-ui/tests/german.rs:24 msgid "File" msgstr "Datei" #: crates/odox-ui/src/i18n.rs:96 crates/odox-ui/src/i18n.rs:97 -#: crates/odox-ui/tests/german.rs:34 crates/xodp/src/view.rs:78 -#: crates/xods/src/view.rs:69 crates/xodt/src/view.rs:30 +#: crates/odox-ui/tests/german.rs:34 crates/xodp/src/view.rs:92 +#: crates/xods/src/view.rs:69 crates/xodt/src/view.rs:41 msgid "{file} could not be opened: {reason}" msgstr "{file} konnte nicht geöffnet werden: {reason}" -#: crates/odox-ui/src/shell.rs:171 +#: crates/odox-ui/src/shell.rs:182 msgid "{file} could not be read: {reason}" msgstr "{file} konnte nicht gelesen werden: {reason}" -#: crates/odox-ui/src/shell.rs:279 +#: crates/odox-ui/src/shell.rs:290 msgid "{file} could not be saved: {reason}" msgstr "{file} konnte nicht gespeichert werden: {reason}" -#: crates/odox-ui/src/shell.rs:329 +#: crates/odox-ui/src/shell.rs:351 msgid "Save changes to {file}?" msgstr "Änderungen an {file} speichern?" -#: crates/odox-ui/src/shell.rs:330 +#: crates/odox-ui/src/shell.rs:352 msgid "The document has changes that are not saved." msgstr "Das Dokument hat ungespeicherte Änderungen." -#: crates/odox-ui/src/shell.rs:333 crates/odox-ui/src/shell.rs:496 +#: crates/odox-ui/src/shell.rs:355 crates/odox-ui/src/shell.rs:522 msgid "Save" msgstr "Speichern" -#: crates/odox-ui/src/shell.rs:336 +#: crates/odox-ui/src/shell.rs:358 msgid "Discard" msgstr "Verwerfen" -#: crates/odox-ui/src/shell.rs:339 +#: crates/odox-ui/src/shell.rs:361 msgid "Cancel" msgstr "Abbrechen" -#: crates/odox-ui/src/shell.rs:400 +#: crates/odox-ui/src/shell.rs:426 msgid "Dismiss" msgstr "Ausblenden" -#: crates/odox-ui/src/shell.rs:474 crates/odox-ui/tests/german.rs:25 +#: crates/odox-ui/src/shell.rs:500 crates/odox-ui/tests/german.rs:25 msgid "Open…" msgstr "Öffnen …" -#: crates/odox-ui/src/shell.rs:480 crates/odox-ui/tests/german.rs:26 +#: crates/odox-ui/src/shell.rs:506 crates/odox-ui/tests/german.rs:26 msgid "Reload" msgstr "Neu einlesen" -#: crates/odox-ui/src/shell.rs:487 +#: crates/odox-ui/src/shell.rs:513 msgid "Close" msgstr "Schließen" -#: crates/odox-ui/src/shell.rs:503 +#: crates/odox-ui/src/shell.rs:529 msgid "Save as…" msgstr "Speichern unter …" -#: crates/odox-ui/src/shell.rs:510 +#: crates/odox-ui/src/shell.rs:536 msgid "Quit" msgstr "Beenden" -#: crates/odox-ui/src/shell.rs:515 +#: crates/odox-ui/src/shell.rs:541 msgid "Edit" msgstr "Bearbeiten" -#: crates/odox-ui/src/shell.rs:517 +#: crates/odox-ui/src/shell.rs:543 msgid "Undo" msgstr "Rückgängig" -#: crates/odox-ui/src/shell.rs:524 +#: crates/odox-ui/src/shell.rs:550 msgid "Redo" msgstr "Wiederholen" -#: crates/odox-ui/src/shell.rs:531 +#: crates/odox-ui/src/shell.rs:557 msgid "Edit mode" msgstr "Bearbeitungsmodus" -#: crates/odox-ui/src/shell.rs:535 +#: crates/odox-ui/src/shell.rs:561 msgid "Open documents in edit mode" msgstr "Dokumente im Bearbeitungsmodus öffnen" -#: crates/odox-ui/src/shell.rs:541 +#: crates/odox-ui/src/shell.rs:567 msgid "The setting could not be saved: {reason}" msgstr "Die Einstellung konnte nicht gespeichert werden: {reason}" -#: crates/odox-ui/src/shell.rs:546 +#: crates/odox-ui/src/shell.rs:572 msgid "View" msgstr "Ansicht" -#: crates/odox-ui/src/shell.rs:547 +#: crates/odox-ui/src/shell.rs:573 msgid "Zoom in" msgstr "Vergrößern" -#: crates/odox-ui/src/shell.rs:550 +#: crates/odox-ui/src/shell.rs:576 msgid "Zoom out" msgstr "Verkleinern" -#: crates/odox-ui/src/shell.rs:553 +#: crates/odox-ui/src/shell.rs:579 msgid "Actual size" msgstr "Originalgröße" -#: crates/odox-ui/src/shell.rs:557 +#: crates/odox-ui/src/shell.rs:583 msgid "Show the side panel" msgstr "Seitenleiste anzeigen" -#: crates/odox-ui/src/shell.rs:563 +#: crates/odox-ui/src/shell.rs:589 msgid "{percent}%" msgstr "{percent} %" -#: crates/odox-ui/src/shell.rs:566 +#: crates/odox-ui/src/shell.rs:592 msgid "Editing" msgstr "Bearbeitung" -#: crates/odox-ui/src/shell.rs:582 +#: crates/odox-ui/src/shell.rs:608 msgid "Open a document…" msgstr "Ein Dokument öffnen …" +#: crates/odox-ui/src/toolbar.rs:26 +msgid "B" +msgstr "F" + +#: crates/odox-ui/src/toolbar.rs:26 +msgid "Bold" +msgstr "Fett" + +#: crates/odox-ui/src/toolbar.rs:27 +msgid "I" +msgstr "K" + +#: crates/odox-ui/src/toolbar.rs:27 +msgid "Italic" +msgstr "Kursiv" + +#: crates/odox-ui/src/toolbar.rs:29 +msgid "U" +msgstr "U" + +#: crates/odox-ui/src/toolbar.rs:30 +msgid "Underline" +msgstr "Unterstrichen" + +#: crates/odox-ui/src/toolbar.rs:34 +msgid "S" +msgstr "D" + +#: crates/odox-ui/src/toolbar.rs:35 +msgid "Strikethrough" +msgstr "Durchgestrichen" + #: crates/odox-ui/tests/german.rs:28 crates/xods/src/main.rs:22 msgid "OpenDocument Spreadsheet" msgstr "OpenDocument-Tabellendokument" @@ -144,23 +176,23 @@ msgstr "OpenDocument-Tabellendokument" msgid "OpenDocument Presentation" msgstr "OpenDocument-Präsentation" -#: crates/xodp/src/view.rs:135 +#: crates/xodp/src/view.rs:149 msgid "This presentation has no slides." msgstr "Diese Präsentation hat keine Folien." -#: crates/xodp/src/view.rs:248 +#: crates/xodp/src/view.rs:264 msgid "Slides" msgstr "Folien" -#: crates/xodp/src/view.rs:270 +#: crates/xodp/src/view.rs:287 msgid "Show the speaker's notes" msgstr "Referentennotizen anzeigen" -#: crates/xodp/src/view.rs:540 +#: crates/xodp/src/view.rs:534 msgid "Notes" msgstr "Notizen" -#: crates/xodp/src/view.rs:547 +#: crates/xodp/src/view.rs:541 msgid "This slide has no notes." msgstr "Diese Folie hat keine Notizen." @@ -177,7 +209,7 @@ msgstr "" msgid "This cell is covered by the one that spans it." msgstr "Diese Zelle wird von der Zelle verdeckt, die sich über sie erstreckt." -#: crates/xods/src/view.rs:678 +#: crates/xods/src/view.rs:680 msgid "There is no cell there." msgstr "Dort ist keine Zelle." @@ -185,19 +217,19 @@ msgstr "Dort ist keine Zelle." msgid "OpenDocument Text" msgstr "OpenDocument-Textdokument" -#: crates/xodt/src/view.rs:79 +#: crates/xodt/src/view.rs:103 msgid "This document has no text in it." msgstr "Dieses Dokument enthält keinen Text." -#: crates/xodt/src/view.rs:129 +#: crates/xodt/src/view.rs:167 msgid "Outline" msgstr "Gliederung" -#: crates/xodt/src/view.rs:138 +#: crates/xodt/src/view.rs:176 msgid "(untitled)" msgstr "(ohne Titel)" -#: crates/xodt/src/view.rs:162 +#: crates/xodt/src/view.rs:200 msgid "Copy the document as text" msgstr "Dokument als Text kopieren" diff --git a/crates/odox-ui/po/en-x-pseudo.po b/crates/odox-ui/po/en-x-pseudo.po index 3d4e900..fe424e1 100644 --- a/crates/odox-ui/po/en-x-pseudo.po +++ b/crates/odox-ui/po/en-x-pseudo.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: odox\n" "Report-Msgid-Bugs-To: https://github.com/excelano/odox/issues\n" -"POT-Creation-Date: 2026-09-21 16:50-0500\n" +"POT-Creation-Date: 2026-09-27 17:57-0500\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: none\n" @@ -18,124 +18,156 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" #: crates/odox-ui/src/i18n.rs:89 crates/odox-ui/src/i18n.rs:93 -#: crates/odox-ui/src/shell.rs:473 crates/odox-ui/tests/german.rs:24 +#: crates/odox-ui/src/shell.rs:499 crates/odox-ui/tests/german.rs:24 msgid "File" msgstr "[Fílé ··]" #: crates/odox-ui/src/i18n.rs:96 crates/odox-ui/src/i18n.rs:97 -#: crates/odox-ui/tests/german.rs:34 crates/xodp/src/view.rs:78 -#: crates/xods/src/view.rs:69 crates/xodt/src/view.rs:30 +#: crates/odox-ui/tests/german.rs:34 crates/xodp/src/view.rs:92 +#: crates/xods/src/view.rs:69 crates/xodt/src/view.rs:41 msgid "{file} could not be opened: {reason}" msgstr "[{file} cöüld ñöt bé öpéñéd: {reason} ···············]" -#: crates/odox-ui/src/shell.rs:171 +#: crates/odox-ui/src/shell.rs:182 msgid "{file} could not be read: {reason}" msgstr "[{file} cöüld ñöt bé réäd: {reason} ··············]" -#: crates/odox-ui/src/shell.rs:279 +#: crates/odox-ui/src/shell.rs:290 msgid "{file} could not be saved: {reason}" msgstr "[{file} cöüld ñöt bé šävéd: {reason} ···············]" -#: crates/odox-ui/src/shell.rs:329 +#: crates/odox-ui/src/shell.rs:351 msgid "Save changes to {file}?" msgstr "[Sävé chäñgéš tö {file}? ··········]" -#: crates/odox-ui/src/shell.rs:330 +#: crates/odox-ui/src/shell.rs:352 msgid "The document has changes that are not saved." msgstr "[Thé döcüméñt häš chäñgéš thät äré ñöt šävéd. ··················]" -#: crates/odox-ui/src/shell.rs:333 crates/odox-ui/src/shell.rs:496 +#: crates/odox-ui/src/shell.rs:355 crates/odox-ui/src/shell.rs:522 msgid "Save" msgstr "[Sävé ··]" -#: crates/odox-ui/src/shell.rs:336 +#: crates/odox-ui/src/shell.rs:358 msgid "Discard" msgstr "[Díšcärd ···]" -#: crates/odox-ui/src/shell.rs:339 +#: crates/odox-ui/src/shell.rs:361 msgid "Cancel" msgstr "[Cäñcél ···]" -#: crates/odox-ui/src/shell.rs:400 +#: crates/odox-ui/src/shell.rs:426 msgid "Dismiss" msgstr "[Díšmíšš ···]" -#: crates/odox-ui/src/shell.rs:474 crates/odox-ui/tests/german.rs:25 +#: crates/odox-ui/src/shell.rs:500 crates/odox-ui/tests/german.rs:25 msgid "Open…" msgstr "[Öpéñ… ···]" -#: crates/odox-ui/src/shell.rs:480 crates/odox-ui/tests/german.rs:26 +#: crates/odox-ui/src/shell.rs:506 crates/odox-ui/tests/german.rs:26 msgid "Reload" msgstr "[Rélöäd ···]" -#: crates/odox-ui/src/shell.rs:487 +#: crates/odox-ui/src/shell.rs:513 msgid "Close" msgstr "[Clöšé ···]" -#: crates/odox-ui/src/shell.rs:503 +#: crates/odox-ui/src/shell.rs:529 msgid "Save as…" msgstr "[Sävé äš… ·····]" -#: crates/odox-ui/src/shell.rs:510 +#: crates/odox-ui/src/shell.rs:536 msgid "Quit" msgstr "[Qüít ··]" -#: crates/odox-ui/src/shell.rs:515 +#: crates/odox-ui/src/shell.rs:541 msgid "Edit" msgstr "[Édít ··]" -#: crates/odox-ui/src/shell.rs:517 +#: crates/odox-ui/src/shell.rs:543 msgid "Undo" msgstr "[Üñdö ··]" -#: crates/odox-ui/src/shell.rs:524 +#: crates/odox-ui/src/shell.rs:550 msgid "Redo" msgstr "[Rédö ··]" -#: crates/odox-ui/src/shell.rs:531 +#: crates/odox-ui/src/shell.rs:557 msgid "Edit mode" msgstr "[Édít mödé ····]" -#: crates/odox-ui/src/shell.rs:535 +#: crates/odox-ui/src/shell.rs:561 msgid "Open documents in edit mode" msgstr "[Öpéñ döcüméñtš íñ édít mödé ···········]" -#: crates/odox-ui/src/shell.rs:541 +#: crates/odox-ui/src/shell.rs:567 msgid "The setting could not be saved: {reason}" msgstr "[Thé šéttíñg cöüld ñöt bé šävéd: {reason} ·················]" -#: crates/odox-ui/src/shell.rs:546 +#: crates/odox-ui/src/shell.rs:572 msgid "View" msgstr "[Víéw ··]" -#: crates/odox-ui/src/shell.rs:547 +#: crates/odox-ui/src/shell.rs:573 msgid "Zoom in" msgstr "[Zööm íñ ···]" -#: crates/odox-ui/src/shell.rs:550 +#: crates/odox-ui/src/shell.rs:576 msgid "Zoom out" msgstr "[Zööm öüt ····]" -#: crates/odox-ui/src/shell.rs:553 +#: crates/odox-ui/src/shell.rs:579 msgid "Actual size" msgstr "[Åctüäl šízé ·····]" -#: crates/odox-ui/src/shell.rs:557 +#: crates/odox-ui/src/shell.rs:583 msgid "Show the side panel" msgstr "[Shöw thé šídé päñél ········]" -#: crates/odox-ui/src/shell.rs:563 +#: crates/odox-ui/src/shell.rs:589 msgid "{percent}%" msgstr "[{percent}% ·····]" -#: crates/odox-ui/src/shell.rs:566 +#: crates/odox-ui/src/shell.rs:592 msgid "Editing" msgstr "[Édítíñg ···]" -#: crates/odox-ui/src/shell.rs:582 +#: crates/odox-ui/src/shell.rs:608 msgid "Open a document…" msgstr "[Öpéñ ä döcüméñt… ········]" +#: crates/odox-ui/src/toolbar.rs:26 +msgid "B" +msgstr "" + +#: crates/odox-ui/src/toolbar.rs:26 +msgid "Bold" +msgstr "" + +#: crates/odox-ui/src/toolbar.rs:27 +msgid "I" +msgstr "" + +#: crates/odox-ui/src/toolbar.rs:27 +msgid "Italic" +msgstr "" + +#: crates/odox-ui/src/toolbar.rs:29 +msgid "U" +msgstr "" + +#: crates/odox-ui/src/toolbar.rs:30 +msgid "Underline" +msgstr "" + +#: crates/odox-ui/src/toolbar.rs:34 +msgid "S" +msgstr "" + +#: crates/odox-ui/src/toolbar.rs:35 +msgid "Strikethrough" +msgstr "" + #: crates/odox-ui/tests/german.rs:28 crates/xods/src/main.rs:22 msgid "OpenDocument Spreadsheet" msgstr "[ÖpéñDöcüméñt Spréädšhéét ··········]" @@ -144,23 +176,23 @@ msgstr "[ÖpéñDöcüméñt Spréädšhéét ··········]" msgid "OpenDocument Presentation" msgstr "[ÖpéñDöcüméñt Préšéñtätíöñ ···········]" -#: crates/xodp/src/view.rs:135 +#: crates/xodp/src/view.rs:149 msgid "This presentation has no slides." msgstr "[Thíš préšéñtätíöñ häš ñö šlídéš. ·············]" -#: crates/xodp/src/view.rs:248 +#: crates/xodp/src/view.rs:264 msgid "Slides" msgstr "[Slídéš ···]" -#: crates/xodp/src/view.rs:270 +#: crates/xodp/src/view.rs:287 msgid "Show the speaker's notes" msgstr "[Shöw thé špéäkér'š ñötéš ··········]" -#: crates/xodp/src/view.rs:540 +#: crates/xodp/src/view.rs:534 msgid "Notes" msgstr "[Nötéš ···]" -#: crates/xodp/src/view.rs:547 +#: crates/xodp/src/view.rs:541 msgid "This slide has no notes." msgstr "[Thíš šlídé häš ñö ñötéš. ··········]" @@ -178,7 +210,7 @@ msgstr "" msgid "This cell is covered by the one that spans it." msgstr "[Thíš céll íš cövéréd by thé öñé thät špäñš ít. ···················]" -#: crates/xods/src/view.rs:678 +#: crates/xods/src/view.rs:680 msgid "There is no cell there." msgstr "[Théré íš ñö céll théré. ··········]" @@ -186,18 +218,18 @@ msgstr "[Théré íš ñö céll théré. ··········]" msgid "OpenDocument Text" msgstr "[ÖpéñDöcüméñt Téxt ·······]" -#: crates/xodt/src/view.rs:79 +#: crates/xodt/src/view.rs:103 msgid "This document has no text in it." msgstr "[Thíš döcüméñt häš ñö téxt íñ ít. ·············]" -#: crates/xodt/src/view.rs:129 +#: crates/xodt/src/view.rs:167 msgid "Outline" msgstr "[Öütlíñé ···]" -#: crates/xodt/src/view.rs:138 +#: crates/xodt/src/view.rs:176 msgid "(untitled)" msgstr "[(üñtítléd) ·····]" -#: crates/xodt/src/view.rs:162 +#: crates/xodt/src/view.rs:200 msgid "Copy the document as text" msgstr "[Cöpy thé döcüméñt äš téxt ···········]" diff --git a/crates/odox-ui/po/odox.pot b/crates/odox-ui/po/odox.pot index 530408a..987a91b 100644 --- a/crates/odox-ui/po/odox.pot +++ b/crates/odox-ui/po/odox.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: odox\n" "Report-Msgid-Bugs-To: https://github.com/excelano/odox/issues\n" -"POT-Creation-Date: 2026-09-21 16:50-0500\n" +"POT-Creation-Date: 2026-09-27 17:57-0500\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,124 +18,156 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" #: crates/odox-ui/src/i18n.rs:89 crates/odox-ui/src/i18n.rs:93 -#: crates/odox-ui/src/shell.rs:473 crates/odox-ui/tests/german.rs:24 +#: crates/odox-ui/src/shell.rs:499 crates/odox-ui/tests/german.rs:24 msgid "File" msgstr "" #: crates/odox-ui/src/i18n.rs:96 crates/odox-ui/src/i18n.rs:97 -#: crates/odox-ui/tests/german.rs:34 crates/xodp/src/view.rs:78 -#: crates/xods/src/view.rs:69 crates/xodt/src/view.rs:30 +#: crates/odox-ui/tests/german.rs:34 crates/xodp/src/view.rs:92 +#: crates/xods/src/view.rs:69 crates/xodt/src/view.rs:41 msgid "{file} could not be opened: {reason}" msgstr "" -#: crates/odox-ui/src/shell.rs:171 +#: crates/odox-ui/src/shell.rs:182 msgid "{file} could not be read: {reason}" msgstr "" -#: crates/odox-ui/src/shell.rs:279 +#: crates/odox-ui/src/shell.rs:290 msgid "{file} could not be saved: {reason}" msgstr "" -#: crates/odox-ui/src/shell.rs:329 +#: crates/odox-ui/src/shell.rs:351 msgid "Save changes to {file}?" msgstr "" -#: crates/odox-ui/src/shell.rs:330 +#: crates/odox-ui/src/shell.rs:352 msgid "The document has changes that are not saved." msgstr "" -#: crates/odox-ui/src/shell.rs:333 crates/odox-ui/src/shell.rs:496 +#: crates/odox-ui/src/shell.rs:355 crates/odox-ui/src/shell.rs:522 msgid "Save" msgstr "" -#: crates/odox-ui/src/shell.rs:336 +#: crates/odox-ui/src/shell.rs:358 msgid "Discard" msgstr "" -#: crates/odox-ui/src/shell.rs:339 +#: crates/odox-ui/src/shell.rs:361 msgid "Cancel" msgstr "" -#: crates/odox-ui/src/shell.rs:400 +#: crates/odox-ui/src/shell.rs:426 msgid "Dismiss" msgstr "" -#: crates/odox-ui/src/shell.rs:474 crates/odox-ui/tests/german.rs:25 +#: crates/odox-ui/src/shell.rs:500 crates/odox-ui/tests/german.rs:25 msgid "Open…" msgstr "" -#: crates/odox-ui/src/shell.rs:480 crates/odox-ui/tests/german.rs:26 +#: crates/odox-ui/src/shell.rs:506 crates/odox-ui/tests/german.rs:26 msgid "Reload" msgstr "" -#: crates/odox-ui/src/shell.rs:487 +#: crates/odox-ui/src/shell.rs:513 msgid "Close" msgstr "" -#: crates/odox-ui/src/shell.rs:503 +#: crates/odox-ui/src/shell.rs:529 msgid "Save as…" msgstr "" -#: crates/odox-ui/src/shell.rs:510 +#: crates/odox-ui/src/shell.rs:536 msgid "Quit" msgstr "" -#: crates/odox-ui/src/shell.rs:515 +#: crates/odox-ui/src/shell.rs:541 msgid "Edit" msgstr "" -#: crates/odox-ui/src/shell.rs:517 +#: crates/odox-ui/src/shell.rs:543 msgid "Undo" msgstr "" -#: crates/odox-ui/src/shell.rs:524 +#: crates/odox-ui/src/shell.rs:550 msgid "Redo" msgstr "" -#: crates/odox-ui/src/shell.rs:531 +#: crates/odox-ui/src/shell.rs:557 msgid "Edit mode" msgstr "" -#: crates/odox-ui/src/shell.rs:535 +#: crates/odox-ui/src/shell.rs:561 msgid "Open documents in edit mode" msgstr "" -#: crates/odox-ui/src/shell.rs:541 +#: crates/odox-ui/src/shell.rs:567 msgid "The setting could not be saved: {reason}" msgstr "" -#: crates/odox-ui/src/shell.rs:546 +#: crates/odox-ui/src/shell.rs:572 msgid "View" msgstr "" -#: crates/odox-ui/src/shell.rs:547 +#: crates/odox-ui/src/shell.rs:573 msgid "Zoom in" msgstr "" -#: crates/odox-ui/src/shell.rs:550 +#: crates/odox-ui/src/shell.rs:576 msgid "Zoom out" msgstr "" -#: crates/odox-ui/src/shell.rs:553 +#: crates/odox-ui/src/shell.rs:579 msgid "Actual size" msgstr "" -#: crates/odox-ui/src/shell.rs:557 +#: crates/odox-ui/src/shell.rs:583 msgid "Show the side panel" msgstr "" -#: crates/odox-ui/src/shell.rs:563 +#: crates/odox-ui/src/shell.rs:589 msgid "{percent}%" msgstr "" -#: crates/odox-ui/src/shell.rs:566 +#: crates/odox-ui/src/shell.rs:592 msgid "Editing" msgstr "" -#: crates/odox-ui/src/shell.rs:582 +#: crates/odox-ui/src/shell.rs:608 msgid "Open a document…" msgstr "" +#: crates/odox-ui/src/toolbar.rs:26 +msgid "B" +msgstr "" + +#: crates/odox-ui/src/toolbar.rs:26 +msgid "Bold" +msgstr "" + +#: crates/odox-ui/src/toolbar.rs:27 +msgid "I" +msgstr "" + +#: crates/odox-ui/src/toolbar.rs:27 +msgid "Italic" +msgstr "" + +#: crates/odox-ui/src/toolbar.rs:29 +msgid "U" +msgstr "" + +#: crates/odox-ui/src/toolbar.rs:30 +msgid "Underline" +msgstr "" + +#: crates/odox-ui/src/toolbar.rs:34 +msgid "S" +msgstr "" + +#: crates/odox-ui/src/toolbar.rs:35 +msgid "Strikethrough" +msgstr "" + #: crates/odox-ui/tests/german.rs:28 crates/xods/src/main.rs:22 msgid "OpenDocument Spreadsheet" msgstr "" @@ -144,23 +176,23 @@ msgstr "" msgid "OpenDocument Presentation" msgstr "" -#: crates/xodp/src/view.rs:135 +#: crates/xodp/src/view.rs:149 msgid "This presentation has no slides." msgstr "" -#: crates/xodp/src/view.rs:248 +#: crates/xodp/src/view.rs:264 msgid "Slides" msgstr "" -#: crates/xodp/src/view.rs:270 +#: crates/xodp/src/view.rs:287 msgid "Show the speaker's notes" msgstr "" -#: crates/xodp/src/view.rs:540 +#: crates/xodp/src/view.rs:534 msgid "Notes" msgstr "" -#: crates/xodp/src/view.rs:547 +#: crates/xodp/src/view.rs:541 msgid "This slide has no notes." msgstr "" @@ -176,7 +208,7 @@ msgstr "" msgid "This cell is covered by the one that spans it." msgstr "" -#: crates/xods/src/view.rs:678 +#: crates/xods/src/view.rs:680 msgid "There is no cell there." msgstr "" @@ -184,18 +216,18 @@ msgstr "" msgid "OpenDocument Text" msgstr "" -#: crates/xodt/src/view.rs:79 +#: crates/xodt/src/view.rs:103 msgid "This document has no text in it." msgstr "" -#: crates/xodt/src/view.rs:129 +#: crates/xodt/src/view.rs:167 msgid "Outline" msgstr "" -#: crates/xodt/src/view.rs:138 +#: crates/xodt/src/view.rs:176 msgid "(untitled)" msgstr "" -#: crates/xodt/src/view.rs:162 +#: crates/xodt/src/view.rs:200 msgid "Copy the document as text" msgstr "" diff --git a/crates/odox-ui/src/flow_model.rs b/crates/odox-ui/src/flow_model.rs index 816837b..0472119 100644 --- a/crates/odox-ui/src/flow_model.rs +++ b/crates/odox-ui/src/flow_model.rs @@ -3,17 +3,20 @@ //! A paragraph is named by its path of child indices from the root the flow //! draws, which is the path [`crate::Flow`] reports it under. The editor's //! edits become `odox-core` edits on the tree, and an edit that begins an undo -//! step is recorded in [`Editing`] once it has succeeded. DESIGN.md §11. +//! step is recorded in [`Editing`] once it has succeeded. A format writes +//! automatic styles, which the document's [`Styles`] is told of as they are +//! written. DESIGN.md §11. // // Author: David M. Anderson // Built with AI assistance (Claude, Anthropic) use std::cell::OnceCell; +use std::ops::Range; use eframe::egui::Id; -use egui_richedit::{Edit, Model, Position, RichEdit}; +use egui_richedit::{Edit, Mark, Model, Position, RichEdit}; use odox_core::edit::{self, is_paragraph}; -use odox_core::{Element, Ns, Refused}; +use odox_core::{Element, Ns, Refused, Styles}; use crate::Editing; use crate::flow::is_block_container; @@ -35,6 +38,7 @@ pub fn page_editor() -> PageEditor { /// The paragraphs under a root in a content tree, for one frame's editing. pub struct FlowModel<'a> { content: &'a mut Element, + styles: &'a mut Styles, /// Where the flow's root is under the content root. root: Vec, editing: &'a mut Editing, @@ -47,10 +51,17 @@ pub struct FlowModel<'a> { } impl<'a> FlowModel<'a> { - /// The paragraphs under the element at a path in a content tree. - pub fn new(content: &'a mut Element, root: Vec, editing: &'a mut Editing) -> Self { + /// The paragraphs under the element at a path in a content tree, whose + /// document's styles are `styles`. + pub fn new( + content: &'a mut Element, + styles: &'a mut Styles, + root: Vec, + editing: &'a mut Editing, + ) -> Self { Self { content, + styles, root, editing, scope: Vec::new(), @@ -125,35 +136,88 @@ impl<'a> FlowModel<'a> { to: &Position>, text: &str, ) -> Option<()> { - let order = self.order(); - let first = order.iter().position(|p| *p == from.paragraph)?; - let last = order.iter().position(|p| *p == to.paragraph)?; - let covered: Vec> = order.get(first..=last)?.to_vec(); - let ranges: Vec<(Vec, usize, usize)> = covered - .into_iter() - .map(|path| { - let len = self.text(&path).map_or(0, |t| t.chars().count()); - let start = if path == from.paragraph { - from.offset - } else { - 0 - }; - let end = if path == to.paragraph { to.offset } else { len }; - (path, start, end.max(start)) - }) - .collect(); - if text.is_empty() && ranges.iter().all(|(_, start, end)| start == end) { + let ranges = self.covered(from, to)?; + if text.is_empty() && ranges.iter().all(|(_, range)| range.is_empty()) { return None; } - for (path, start, end) in ranges.into_iter().rev() { + for (path, range) in ranges.into_iter().rev() { let with = if path == from.paragraph { text } else { "" }; let paragraph = self.paragraph_mut(&path)?; - edit::replace(paragraph, start..end, with); + edit::replace(paragraph, range, with); + } + Some(()) + } + + /// Each paragraph from one position to another in drawing order, with + /// the range of its text between them. + fn covered( + &self, + from: &Position>, + to: &Position>, + ) -> Option, Range)>> { + let order = self.order(); + let first = order.iter().position(|p| *p == from.paragraph)?; + let last = order.iter().position(|p| *p == to.paragraph)?; + let covered: Vec> = order.get(first..=last)?.to_vec(); + Some( + covered + .into_iter() + .map(|path| { + let len = self.text(&path).map_or(0, |t| t.chars().count()); + let start = if path == from.paragraph { + from.offset + } else { + 0 + }; + let end = if path == to.paragraph { to.offset } else { len }; + (path, start..end.max(start)) + }) + .collect(), + ) + } + + /// Give the text from one position to another a mark, or take it off, + /// paragraph by paragraph. + fn format( + &mut self, + from: &Position>, + to: &Position>, + mark: edit::Mark, + on: bool, + ) -> Option<()> { + let automatic = + |content: &Element| content.child(&Ns::Office, "automatic-styles").is_some(); + for (path, range) in self.covered(from, to)? { + if range.is_empty() { + continue; + } + let had = automatic(self.content); + let mut at = self.root.clone(); + at.extend(&path); + edit::format(self.content, &at, range, mark, on, self.styles).ok()?; + // Styles written into a document that had nowhere to keep them go + // before its body, and move the body, and the root, one along. + if !had + && automatic(self.content) + && let Some(first) = self.root.first_mut() + { + *first += 1; + } } Some(()) } } +/// The library's name for a mark. +fn core_mark(mark: Mark) -> edit::Mark { + match mark { + Mark::Bold => edit::Mark::Bold, + Mark::Italic => edit::Mark::Italic, + Mark::Underline => edit::Mark::Underline, + Mark::Strike => edit::Mark::Strike, + } +} + impl Model for FlowModel<'_> { type Paragraph = Vec; @@ -180,6 +244,34 @@ impl Model for FlowModel<'_> { self.order().last().cloned() } + fn marked( + &self, + from: &Position>, + to: &Position>, + mark: Mark, + ) -> Option { + let root = self.root()?; + let mark = core_mark(mark); + let ranges: Vec<_> = self + .covered(from, to)? + .into_iter() + .filter(|(_, range)| !range.is_empty()) + .collect(); + if ranges.is_empty() { + let at = from.offset..from.offset; + return edit::marked(root.at(&from.paragraph)?, at, mark, self.styles); + } + let mut answer = None; + for (path, range) in ranges { + let value = edit::marked(root.at(&path)?, range, mark, self.styles)?; + if answer.is_some_and(|a| a != value) { + return None; + } + answer = Some(value); + } + answer + } + fn apply( &mut self, edit: Edit<'_, Vec>, @@ -194,7 +286,7 @@ impl Model for FlowModel<'_> { // Where the edit begins is where an undo of it puts the caret. let before = new_step.then(|| self.content.clone()); let begins = match &edit { - Edit::Replace { from, .. } => from.clone(), + Edit::Replace { from, .. } | Edit::Format { from, .. } => from.clone(), Edit::Split { at } => at.clone(), }; let at = match edit { @@ -207,6 +299,9 @@ impl Model for FlowModel<'_> { .ok() .map(|second| Position::new(second, 0)) } + Edit::Format { from, to, mark, on } => { + self.format(&from, &to, core_mark(mark), on).map(|()| to) + } }; if at.is_some() { if let Some(before) = before { @@ -276,7 +371,7 @@ mod tests { use super::FlowModel; use crate::Editing; use egui_richedit::{Edit, Model, Position}; - use odox_core::{Element, Node, Ns}; + use odox_core::{Element, Node, Ns, Styles}; fn element(prefix: &str, local: &str, ns: Ns, children: Vec) -> Node { let mut e = Element::new(prefix, local, ns); @@ -322,8 +417,9 @@ mod tests { #[test] fn paragraphs_follow_one_another_through_lists_and_tables() { let mut content = content(); + let mut styles = Styles::collect(Some(&content), None); let mut editing = Editing::default(); - let model = FlowModel::new(&mut content, vec![0, 0], &mut editing); + let model = FlowModel::new(&mut content, &mut styles, vec![0, 0], &mut editing); let mut at = vec![0]; let mut seen = vec![model.text(&at).expect("the first")]; while let Some(next) = model.next(&at) { @@ -337,9 +433,10 @@ mod tests { #[test] fn a_selection_over_a_whole_table_takes_it_and_joins_the_ends() { let mut content = content(); + let mut styles = Styles::collect(Some(&content), None); let mut editing = Editing::default(); editing.reset(); - let mut model = FlowModel::new(&mut content, vec![0, 0], &mut editing); + let mut model = FlowModel::new(&mut content, &mut styles, vec![0, 0], &mut editing); // From inside "item" to inside "two", over the table's one cell. let at = model.apply( Edit::Replace { @@ -361,9 +458,10 @@ mod tests { #[test] fn a_selection_out_of_a_cell_takes_the_text_and_leaves_the_cell() { let mut content = content(); + let mut styles = Styles::collect(Some(&content), None); let mut editing = Editing::default(); editing.reset(); - let mut model = FlowModel::new(&mut content, vec![0, 0], &mut editing); + let mut model = FlowModel::new(&mut content, &mut styles, vec![0, 0], &mut editing); // From inside "cell" to inside "two". let at = model.apply( Edit::Replace { @@ -381,9 +479,10 @@ mod tests { #[test] fn backspace_at_a_list_item_joins_it_to_the_paragraph_before() { let mut content = content(); + let mut styles = Styles::collect(Some(&content), None); let mut editing = Editing::default(); editing.reset(); - let mut model = FlowModel::new(&mut content, vec![0, 0], &mut editing); + let mut model = FlowModel::new(&mut content, &mut styles, vec![0, 0], &mut editing); let at = model.apply( Edit::Replace { from: Position::new(vec![0], 3), @@ -401,6 +500,7 @@ mod tests { #[test] fn typing_that_runs_on_past_a_save_is_a_change() { let mut content = content(); + let mut styles = Styles::collect(Some(&content), None); let mut editing = Editing::default(); editing.reset(); let at = |offset| Position::new(vec![0], offset); @@ -409,7 +509,7 @@ mod tests { to: at(offset), text: "x", }; - let mut model = FlowModel::new(&mut content, vec![0, 0], &mut editing); + let mut model = FlowModel::new(&mut content, &mut styles, vec![0, 0], &mut editing); model.apply(typed(0), true); model.editing.mark_saved(); // The same run of typing, as far as the editor knows. @@ -423,9 +523,10 @@ mod tests { #[test] fn an_edit_that_begins_a_step_is_recorded_once_it_succeeds() { let mut content = content(); + let mut styles = Styles::collect(Some(&content), None); let mut editing = Editing::default(); editing.reset(); - let mut model = FlowModel::new(&mut content, vec![0, 0], &mut editing); + let mut model = FlowModel::new(&mut content, &mut styles, vec![0, 0], &mut editing); let at = model.apply( Edit::Split { at: Position::new(vec![0], 1), diff --git a/crates/odox-ui/src/lib.rs b/crates/odox-ui/src/lib.rs index ba65888..8a05960 100644 --- a/crates/odox-ui/src/lib.rs +++ b/crates/odox-ui/src/lib.rs @@ -36,6 +36,7 @@ pub mod settings; pub mod shapes; pub mod shell; pub mod system_theme; +pub mod toolbar; pub use edit::{Caret, Editing}; pub use flow::{Flow, Pictures}; diff --git a/crates/odox-ui/src/toolbar.rs b/crates/odox-ui/src/toolbar.rs new file mode 100644 index 0000000..dd7bde2 --- /dev/null +++ b/crates/odox-ui/src/toolbar.rs @@ -0,0 +1,58 @@ +//! The row of buttons over a page being edited. DESIGN.md §11. +// +// Author: David M. Anderson +// Built with AI assistance (Claude, Anthropic) + +use eframe::egui::{Key, KeyboardShortcut, Modifiers, RichText, Ui}; +use egui_richedit::{Mark, Model}; + +use crate::PageEditor; +use crate::i18n::t; + +/// Bold, italic, underline and strikethrough, each lit where the whole +/// selection has it, and none of them live without a caret. Answers the mark +/// whose button was pressed, for the editor to toggle over the model. +pub fn marks>>( + ui: &mut Ui, + editor: &PageEditor, + model: &M, +) -> Option { + let mut pressed = None; + ui.add_enabled_ui(editor.selection().is_some(), |ui| { + ui.horizontal(|ui| { + for mark in Mark::ALL { + let lit = editor.marked(model, mark) == Some(true); + let (label, name, key) = match mark { + Mark::Bold => (RichText::new(t("B")).strong(), t("Bold"), Some(Key::B)), + Mark::Italic => (RichText::new(t("I")).italics(), t("Italic"), Some(Key::I)), + Mark::Underline => ( + RichText::new(t("U")).underline(), + t("Underline"), + Some(Key::U), + ), + Mark::Strike => ( + RichText::new(t("S")).strikethrough(), + t("Strikethrough"), + None, + ), + }; + let hint = match key { + Some(key) => format!( + "{name} ({})", + ui.ctx() + .format_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, key)) + ), + None => name.to_owned(), + }; + if ui + .selectable_label(lit, label) + .on_hover_text(hint) + .clicked() + { + pressed = Some(mark); + } + } + }); + }); + pressed +} diff --git a/crates/xodp/src/view.rs b/crates/xodp/src/view.rs index 757e121..7f2334d 100644 --- a/crates/xodp/src/view.rs +++ b/crates/xodp/src/view.rs @@ -174,22 +174,8 @@ impl View for SlideView { let dragging = self.drag.is_some(); let mut action = None; - // What was typed goes into the tree before it is drawn, kept to the - // one shape the caret is in. - if edit_mode - && let Some(document) = &mut self.document - && let Some(position) = document.slides().get(slide_index).map(|s| s.position) - && let Some(root) = document.page_path(position) - { - let scope = self - .page_editor - .selection() - .and_then(|selection| selection.focus.paragraph.first().copied()) - .into_iter() - .collect(); - let mut model = - FlowModel::new(&mut document.document.content, root, editing).within(scope); - self.page_editor.input(ui, &mut model); + if edit_mode { + self.edit(ui, editing); } let Some(document) = &self.document else { @@ -303,6 +289,37 @@ impl View for SlideView { } impl SlideView { + /// The toolbar, and what was typed, which goes into the tree before it is + /// drawn, kept to the one shape the caret is in. + fn edit(&mut self, ui: &mut Ui, editing: &mut Editing) { + let Some(document) = &mut self.document else { + return; + }; + let Some(root) = document + .slides() + .get(self.slide) + .map(|s| s.position) + .and_then(|position| document.page_path(position)) + else { + return; + }; + let scope = self + .page_editor + .selection() + .and_then(|selection| selection.focus.paragraph.first().copied()) + .into_iter() + .collect(); + let Document { + content, styles, .. + } = &mut document.document; + let mut model = FlowModel::new(content, styles, root, editing).within(scope); + if let Some(mark) = odox_ui::toolbar::marks(ui, &self.page_editor, &model) { + self.page_editor.toggle(&mut model, mark); + } + ui.separator(); + self.page_editor.input(ui, &mut model); + } + /// Apply what the slide asked for. fn act(&mut self, action: Action, scale: f32, editing: &mut Editing) { match action { diff --git a/crates/xods/src/view.rs b/crates/xods/src/view.rs index f7a7f39..141f103 100644 --- a/crates/xods/src/view.rs +++ b/crates/xods/src/view.rs @@ -675,9 +675,9 @@ fn notice(refused: &Refused) -> String { match refused { Refused::Formula => t("This cell holds a formula, which this version does not edit."), Refused::Covered => t("This cell is covered by the one that spans it."), - // A cell is never a range, so a refusal over structure is not one a - // cell hears. - Refused::NotFound | Refused::Structure => t("There is no cell there."), + // A cell is never a range and is not formatted here, so a refusal + // over structure or a namespace is not one a cell hears. + Refused::NotFound | Refused::Structure | Refused::Namespace => t("There is no cell there."), } .to_owned() } diff --git a/crates/xodt/src/view.rs b/crates/xodt/src/view.rs index b5d334d..c9b5661 100644 --- a/crates/xodt/src/view.rs +++ b/crates/xodt/src/view.rs @@ -112,13 +112,27 @@ impl View for TextView { let page = document.text_width() * zoom; let margin = 24.0 * zoom; + if edit_mode && let Some(root) = document.body_path() { + let Document { + content, styles, .. + } = &mut document.document; + let mut model = FlowModel::new(content, styles, root, editing); + if let Some(mark) = odox_ui::toolbar::marks(ui, &self.page, &model) { + self.page.toggle(&mut model, mark); + } + ui.separator(); + } + egui::ScrollArea::both() .auto_shrink([false, false]) .show(ui, |ui| { // What was typed goes into the tree before it is laid out, so // this frame already draws it. if edit_mode && let Some(root) = document.body_path() { - let mut model = FlowModel::new(&mut document.document.content, root, editing); + let Document { + content, styles, .. + } = &mut document.document; + let mut model = FlowModel::new(content, styles, root, editing); self.page.input(ui, &mut model); } let Some(body) = document.body() else { diff --git a/packaging/debian/build-deb.sh b/packaging/debian/build-deb.sh index a0d9eba..f82bd7f 100755 --- a/packaging/debian/build-deb.sh +++ b/packaging/debian/build-deb.sh @@ -18,6 +18,9 @@ # Unversioned, because there is no coupling to version: the launcher finds an # editor by name and hands the file over, so any version of one works with any # version of the other. +# +# Author: David M. Anderson +# Built with AI assistance (Claude, Anthropic) set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -104,7 +107,7 @@ for app in xodt xods xodp odox; do else app_depends="$depends" recommends="fonts-liberation" - closing="Editing means changing what is there, not authoring: no formatting, no inserting, no formulas. What was not touched is written back as it was read, element for element. Nothing is sent anywhere, and nothing is written until Save, and then only the file that was opened or the one named." + closing="Editing means changing what is there, not authoring: no formatting beyond bold, italic, underline and strikethrough, no inserting, no formulas. What was not touched is written back as it was read, element for element. Nothing is sent anywhere, and nothing is written until Save, and then only the file that was opened or the one named." fi # A control file's extended description is one space-prefixed line per line, # and lintian refuses one longer than eighty columns. diff --git a/packaging/debian/manual.1.in b/packaging/debian/manual.1.in index 8907c58..093a85e 100644 --- a/packaging/debian/manual.1.in +++ b/packaging/debian/manual.1.in @@ -10,8 +10,9 @@ The document is read from the file and nothing is sent anywhere. Editing means changing what is there, not authoring: text can be typed and deleted, a paragraph split or joined, a cell given a value, a shape moved or resized, any -of it undone, and the document saved. Formatting, inserting, formulas and find -and replace are an office suite's. What was not touched is written back as it +of it undone, and the document saved; in a text document or on a slide, text +can be made bold, italic, underlined or struck through. Other formatting, +inserting, formulas and find and replace are an office suite's. What was not touched is written back as it was read, element for element, so the saved document is the opened one with the change in it. Nothing is written until Save, and then only the file that was opened or the one named; before it is, what is about to be written is read back @@ -36,6 +37,11 @@ Save over the open file; save somewhere else. .B Ctrl+Z, Ctrl+Shift+Z Take an edit back; put it back. .TP +.B Ctrl+B, Ctrl+I, Ctrl+U +In a text document or on a slide, make the selection bold, italic or +underlined, or take it off where all of it is; with nothing selected, what is +typed next. +.TP .B Ctrl+E Edit mode on or off. A window opens reading unless the preference in the Edit menu says otherwise. diff --git a/packaging/release-notes.toml b/packaging/release-notes.toml index 084a2fd..65640ad 100644 --- a/packaging/release-notes.toml +++ b/packaging/release-notes.toml @@ -11,14 +11,17 @@ # # Nothing else reads this. The GitHub release, the Debian changelog and the # AppStream entry take commit subjects. +# +# Author: David M. Anderson +# Built with AI assistance (Claude, Anthropic) version = "0.4.0" [products.xodt.locales.en-us] -whats-new = "Odox now edits. A document opens reading; Edit mode, from the Edit menu or Ctrl+E, lets you change what is there: text in a paragraph, a value in a cell, the place and size of a shape on a slide, and the text in it. Undo takes it back and Save writes the document back as it was opened, with your change in it. What you did not touch is written back as it was read, element for element, and a save that would not read back the same is refused rather than written. One preference, opening documents ready to edit, is kept in a settings file that is written only when you change it." +whats-new = "Odox now edits. A document opens reading; Edit mode, from the Edit menu or Ctrl+E, lets you change what is there: text in a paragraph, a value in a cell, the place and size of a shape on a slide, and the text in it. Text in a document or on a slide can also be made bold, italic, underlined or struck through, with Ctrl+B, Ctrl+I and Ctrl+U or the buttons above the page. Undo takes it back and Save writes the document back as it was opened, with your change in it. What you did not touch is written back as it was read, element for element, and a save that would not read back the same is refused rather than written. One preference, opening documents ready to edit, is kept in a settings file that is written only when you change it." [products.xodt.locales.de-de] -whats-new = "Odox bearbeitet jetzt. Ein Dokument öffnet sich zum Lesen; der Bearbeitungsmodus, im Menü Bearbeiten oder mit Strg+E, lässt Sie ändern, was da ist: Text in einem Absatz, einen Wert in einer Zelle, Ort und Größe einer Form auf einer Folie und ihren Text. Rückgängig nimmt es zurück, und Speichern schreibt das Dokument so zurück, wie es geöffnet wurde, mit Ihrer Änderung darin. Was Sie nicht angefasst haben, wird so zurückgeschrieben, wie es gelesen wurde, Element für Element, und ein Speichern, das nicht genauso zurückgelesen würde, wird verweigert, statt geschrieben zu werden. Eine Einstellung, ob Dokumente bereit zum Bearbeiten geöffnet werden, wird in einer Einstellungsdatei abgelegt, die nur geschrieben wird, wenn Sie sie ändern." +whats-new = "Odox bearbeitet jetzt. Ein Dokument öffnet sich zum Lesen; der Bearbeitungsmodus, im Menü Bearbeiten oder mit Strg+E, lässt Sie ändern, was da ist: Text in einem Absatz, einen Wert in einer Zelle, Ort und Größe einer Form auf einer Folie und ihren Text. Text in einem Dokument oder auf einer Folie lässt sich außerdem fett, kursiv, unterstrichen oder durchgestrichen formatieren, mit Strg+B, Strg+I und Strg+U oder den Schaltflächen über der Seite. Rückgängig nimmt es zurück, und Speichern schreibt das Dokument so zurück, wie es geöffnet wurde, mit Ihrer Änderung darin. Was Sie nicht angefasst haben, wird so zurückgeschrieben, wie es gelesen wurde, Element für Element, und ein Speichern, das nicht genauso zurückgelesen würde, wird verweigert, statt geschrieben zu werden. Eine Einstellung, ob Dokumente bereit zum Bearbeiten geöffnet werden, wird in einer Einstellungsdatei abgelegt, die nur geschrieben wird, wenn Sie sie ändern." [products.xods.locales.en-us] whats-new = "Odox now edits. A document opens reading; Edit mode, from the Edit menu or Ctrl+E, lets you change what is there: text in a paragraph, a value in a cell, the place and size of a shape on a slide, and the text in it. Undo takes it back and Save writes the document back as it was opened, with your change in it. What you did not touch is written back as it was read, element for element, and a save that would not read back the same is refused rather than written. One preference, opening documents ready to edit, is kept in a settings file that is written only when you change it." @@ -27,7 +30,7 @@ whats-new = "Odox now edits. A document opens reading; Edit mode, from the Edit whats-new = "Odox bearbeitet jetzt. Ein Dokument öffnet sich zum Lesen; der Bearbeitungsmodus, im Menü Bearbeiten oder mit Strg+E, lässt Sie ändern, was da ist: Text in einem Absatz, einen Wert in einer Zelle, Ort und Größe einer Form auf einer Folie und ihren Text. Rückgängig nimmt es zurück, und Speichern schreibt das Dokument so zurück, wie es geöffnet wurde, mit Ihrer Änderung darin. Was Sie nicht angefasst haben, wird so zurückgeschrieben, wie es gelesen wurde, Element für Element, und ein Speichern, das nicht genauso zurückgelesen würde, wird verweigert, statt geschrieben zu werden. Eine Einstellung, ob Dokumente bereit zum Bearbeiten geöffnet werden, wird in einer Einstellungsdatei abgelegt, die nur geschrieben wird, wenn Sie sie ändern." [products.xodp.locales.en-us] -whats-new = "Odox now edits. A document opens reading; Edit mode, from the Edit menu or Ctrl+E, lets you change what is there: text in a paragraph, a value in a cell, the place and size of a shape on a slide, and the text in it. Undo takes it back and Save writes the document back as it was opened, with your change in it. What you did not touch is written back as it was read, element for element, and a save that would not read back the same is refused rather than written. One preference, opening documents ready to edit, is kept in a settings file that is written only when you change it." +whats-new = "Odox now edits. A document opens reading; Edit mode, from the Edit menu or Ctrl+E, lets you change what is there: text in a paragraph, a value in a cell, the place and size of a shape on a slide, and the text in it. Text in a document or on a slide can also be made bold, italic, underlined or struck through, with Ctrl+B, Ctrl+I and Ctrl+U or the buttons above the page. Undo takes it back and Save writes the document back as it was opened, with your change in it. What you did not touch is written back as it was read, element for element, and a save that would not read back the same is refused rather than written. One preference, opening documents ready to edit, is kept in a settings file that is written only when you change it." [products.xodp.locales.de-de] -whats-new = "Odox bearbeitet jetzt. Ein Dokument öffnet sich zum Lesen; der Bearbeitungsmodus, im Menü Bearbeiten oder mit Strg+E, lässt Sie ändern, was da ist: Text in einem Absatz, einen Wert in einer Zelle, Ort und Größe einer Form auf einer Folie und ihren Text. Rückgängig nimmt es zurück, und Speichern schreibt das Dokument so zurück, wie es geöffnet wurde, mit Ihrer Änderung darin. Was Sie nicht angefasst haben, wird so zurückgeschrieben, wie es gelesen wurde, Element für Element, und ein Speichern, das nicht genauso zurückgelesen würde, wird verweigert, statt geschrieben zu werden. Eine Einstellung, ob Dokumente bereit zum Bearbeiten geöffnet werden, wird in einer Einstellungsdatei abgelegt, die nur geschrieben wird, wenn Sie sie ändern." +whats-new = "Odox bearbeitet jetzt. Ein Dokument öffnet sich zum Lesen; der Bearbeitungsmodus, im Menü Bearbeiten oder mit Strg+E, lässt Sie ändern, was da ist: Text in einem Absatz, einen Wert in einer Zelle, Ort und Größe einer Form auf einer Folie und ihren Text. Text in einem Dokument oder auf einer Folie lässt sich außerdem fett, kursiv, unterstrichen oder durchgestrichen formatieren, mit Strg+B, Strg+I und Strg+U oder den Schaltflächen über der Seite. Rückgängig nimmt es zurück, und Speichern schreibt das Dokument so zurück, wie es geöffnet wurde, mit Ihrer Änderung darin. Was Sie nicht angefasst haben, wird so zurückgeschrieben, wie es gelesen wurde, Element für Element, und ein Speichern, das nicht genauso zurückgelesen würde, wird verweigert, statt geschrieben zu werden. Eine Einstellung, ob Dokumente bereit zum Bearbeiten geöffnet werden, wird in einer Einstellungsdatei abgelegt, die nur geschrieben wird, wenn Sie sie ändern." diff --git a/packaging/store-listing.toml b/packaging/store-listing.toml index b2915f8..e5385bc 100644 --- a/packaging/store-listing.toml +++ b/packaging/store-listing.toml @@ -58,9 +58,12 @@ # slide's text, and drag or resize a shape. Save writes the document back over # the file that was opened and Save As writes it elsewhere; nothing else is # written, except one preference, in a settings file, when it is changed in -# the menu. The bundle declares CFBundleTypeRole as Editor. There is no -# Export, no formatting, no inserting and no formula editing, which is the -# scope of the application rather than a limitation of the review build. +# the menu. In a text document or a slide's text, Ctrl+B, Ctrl+I and Ctrl+U, +# or the buttons above the page, make the selection bold, italic or underlined, +# and a button strikes it through. The bundle declares CFBundleTypeRole as +# Editor. There is no Export, no other formatting, no inserting and no formula +# editing, which is the scope of the application rather than a limitation of +# the review build. # # Each declares its one OpenDocument type at rank Alternate rather than # Default or Owner. OpenDocument is an OASIS standard that this project reads @@ -85,7 +88,7 @@ description = """ Odox Text opens an OpenDocument text document, lets you change what is in it, and saves it back as the document it was. -It is a lightweight editor and not a word processor. A document opens reading; Edit mode, from the Edit menu or Ctrl+E, lets you click a paragraph and change its text where it sits, start a new paragraph with Enter, join two with Backspace, take any of it back with Undo, and save. You cannot apply formatting, insert a table or a picture, or find and replace: for that you have an office suite. Nothing is written until you press Save, and then only the file you opened or the one you named; no temporary file is left beside it and no backup is kept. The one preference the application keeps, whether a document opens ready to edit, goes into a settings file only when you change it. There is no recent-documents list and no cache. +It is a lightweight editor and not a word processor. A document opens reading; Edit mode, from the Edit menu or Ctrl+E, lets you click a paragraph and change its text where it sits, start a new paragraph with Enter, join two with Backspace, make text bold, italic, underlined or struck through, take any of it back with Undo, and save. You cannot apply other formatting, insert a table or a picture, or find and replace: for that you have an office suite. Nothing is written until you press Save, and then only the file you opened or the one you named; no temporary file is left beside it and no backup is kept. The one preference the application keeps, whether a document opens ready to edit, goes into a settings file only when you change it. There is no recent-documents list and no cache. It draws the page at the width the document asks for. Headings, nested lists, tables with merged cells and borders, and the pictures inside the document appear where the document puts them, in the fonts it asks for, resolved against the ones you have installed. An outline sits beside the page and moves it when you click a heading. You can select across paragraphs and copy, or take the whole document as plain text in one command. @@ -121,6 +124,7 @@ features = [ "An outline beside the page that moves it when you click a heading", "Select across paragraphs and copy, or copy the whole document as plain text", "Click a paragraph to change it, split it with Enter or join it with Backspace, and take any of it back with Undo", + "Make text bold, italic, underlined or struck through, from the keyboard or the buttons above the page", "Saves over the file you opened or somewhere else, and refuses a save that would not read back as the document in the window", "What you did not touch is written back as it was read, element for element, including what Odox has never heard of", "Writes nothing else: no temporary file left beside your document, no backup, no cache; one preference in a settings file, only when you change it", @@ -135,7 +139,7 @@ keywords = ["opendocument", "odf", "odt", "document editor", "edit document", "t description = """ Odox Text öffnet ein OpenDocument-Textdokument, lässt Sie ändern, was darin steht, und speichert es als das Dokument zurück, das es war. -Es ist ein leichter Editor und keine Textverarbeitung. Ein Dokument öffnet sich zum Lesen; der Bearbeitungsmodus, im Menü Bearbeiten oder mit Strg+E, lässt Sie auf einen Absatz klicken und seinen Text an Ort und Stelle ändern, mit Enter einen neuen Absatz beginnen, mit der Rücktaste zwei zusammenfügen, alles mit Rückgängig zurücknehmen und speichern. Formatieren, eine Tabelle oder ein Bild einfügen, suchen und ersetzen: dafür haben Sie eine Bürosuite. Es wird nichts geschrieben, bis Sie speichern, und dann nur die Datei, die Sie geöffnet haben, oder die, die Sie benannt haben; keine temporäre Datei bleibt daneben, keine Sicherungskopie. Die eine Einstellung, die die Anwendung sich merkt, ob ein Dokument zum Bearbeiten geöffnet wird, landet nur dann in einer Einstellungsdatei, wenn Sie sie ändern. Es gibt keine Liste zuletzt geöffneter Dokumente und keinen Zwischenspeicher. +Es ist ein leichter Editor und keine Textverarbeitung. Ein Dokument öffnet sich zum Lesen; der Bearbeitungsmodus, im Menü Bearbeiten oder mit Strg+E, lässt Sie auf einen Absatz klicken und seinen Text an Ort und Stelle ändern, mit Enter einen neuen Absatz beginnen, mit der Rücktaste zwei zusammenfügen, Text fett, kursiv, unterstrichen oder durchgestrichen formatieren, alles mit Rückgängig zurücknehmen und speichern. Anders formatieren, eine Tabelle oder ein Bild einfügen, suchen und ersetzen: dafür haben Sie eine Bürosuite. Es wird nichts geschrieben, bis Sie speichern, und dann nur die Datei, die Sie geöffnet haben, oder die, die Sie benannt haben; keine temporäre Datei bleibt daneben, keine Sicherungskopie. Die eine Einstellung, die die Anwendung sich merkt, ob ein Dokument zum Bearbeiten geöffnet wird, landet nur dann in einer Einstellungsdatei, wenn Sie sie ändern. Es gibt keine Liste zuletzt geöffneter Dokumente und keinen Zwischenspeicher. Die Seite wird in der Breite gezeichnet, die das Dokument verlangt. Überschriften, verschachtelte Listen, Tabellen mit verbundenen Zellen und Rahmen sowie die Bilder im Dokument erscheinen dort, wo das Dokument sie hinsetzt, in den Schriften, die es verlangt, aufgelöst gegen die auf Ihrem Rechner installierten. Neben der Seite steht eine Gliederung, die beim Klick auf eine Überschrift dorthin springt. Sie können über Absätze hinweg markieren und kopieren oder das ganze Dokument mit einem Befehl als reinen Text übernehmen. @@ -171,6 +175,7 @@ features = [ "Eine Gliederung neben der Seite, die beim Klick auf eine Überschrift dorthin springt", "Über Absätze hinweg markieren und kopieren, oder das ganze Dokument als reinen Text übernehmen", "Auf einen Absatz klicken und ihn ändern, mit Enter teilen oder mit der Rücktaste zusammenfügen, und alles mit Rückgängig zurücknehmen", + "Text fett, kursiv, unterstrichen oder durchgestrichen formatieren, über die Tastatur oder die Schaltflächen über der Seite", "Speichert über die geöffnete Datei oder anderswohin und verweigert ein Speichern, das nicht als das Dokument im Fenster zurückgelesen würde", "Was Sie nicht angefasst haben, wird so zurückgeschrieben, wie es gelesen wurde, Element für Element, auch das, wovon Odox nie gehört hat", "Schreibt sonst nichts: keine temporäre Datei neben Ihrem Dokument, keine Sicherungskopie, kein Zwischenspeicher; eine Einstellung in einer Einstellungsdatei, nur wenn Sie sie ändern", @@ -291,7 +296,7 @@ keywords = ["OpenDocument", "ODF", "ODS", "Tabellendokument", "Tabelle", "Editor description = """ Odox Deck opens an OpenDocument presentation, lets you change what is on its slides, and saves it back as the deck it was. -It is a lightweight editor and not a presentation application. A deck opens reading; Edit mode, from the Edit menu or Ctrl+E, lets you pick a shape and drag it where it should be, resize it by a corner, click its text and change it where it sits, take any of it back with Undo, and save. You cannot add a slide or a shape, apply formatting, or change a master page: for that you have an office suite. Nothing is written until you press Save, and then only the file you opened or the one you named; no temporary file is left beside it and no backup is kept. The one preference the application keeps, whether a document opens ready to edit, goes into a settings file only when you change it. There is no recent-documents list and no cache. +It is a lightweight editor and not a presentation application. A deck opens reading; Edit mode, from the Edit menu or Ctrl+E, lets you pick a shape and drag it where it should be, resize it by a corner, click its text and change it where it sits, make that text bold, italic, underlined or struck through, take any of it back with Undo, and save. You cannot add a slide or a shape, apply other formatting, or change a master page: for that you have an office suite. Nothing is written until you press Save, and then only the file you opened or the one you named; no temporary file is left beside it and no backup is kept. The one preference the application keeps, whether a document opens ready to edit, goes into a settings file only when you change it. There is no recent-documents list and no cache. It draws each slide at the size the document sets. Behind the slide's own text and pictures is the master page: the ground it fills and the decorations a template puts on every slide, which is most of what makes a deck look like the template it was built from. The shapes are drawn from the geometry the document states rather than approximated into rectangles, which covers paths, custom shapes with their own formulas, connectors routed between the shapes they join, and the labels a shape carries. The speaker's notes sit underneath the slide and a list of slides beside it. @@ -330,6 +335,7 @@ features = [ "Solid, linear and axial gradient fills, and the pictures a slide frames", "The speaker's notes underneath each slide, and a list of slides beside", "Pick a shape and drag it, resize it by a corner, or click its text to change it where it sits, and take any of it back with Undo", + "Make a shape's text bold, italic, underlined or struck through", "Saves over the file you opened or somewhere else, and refuses a save that would not read back as the document in the window", "What you did not touch is written back as it was read, element for element, the master page and the template's decorations included", "Writes nothing else: no temporary file left beside your document, no backup, no cache; one preference in a settings file, only when you change it", @@ -344,7 +350,7 @@ keywords = ["opendocument", "odf", "odp", "presentation editor", "edit slides", description = """ Odox Deck öffnet eine OpenDocument-Präsentation, lässt Sie ändern, was auf den Folien steht, und speichert sie als den Foliensatz zurück, der sie war. -Es ist ein leichter Editor und kein Präsentationsprogramm. Ein Foliensatz öffnet sich zum Lesen; der Bearbeitungsmodus, im Menü Bearbeiten oder mit Strg+E, lässt Sie eine Form wählen und dorthin ziehen, wo sie hingehört, ihre Größe an einer Ecke ändern, auf ihren Text klicken und ihn an Ort und Stelle ändern, alles mit Rückgängig zurücknehmen und speichern. Eine Folie oder eine Form hinzufügen, formatieren oder eine Masterfolie ändern: dafür haben Sie eine Bürosuite. Es wird nichts geschrieben, bis Sie speichern, und dann nur die Datei, die Sie geöffnet haben, oder die, die Sie benannt haben; keine temporäre Datei bleibt daneben, keine Sicherungskopie. Die eine Einstellung, die die Anwendung sich merkt, ob ein Dokument zum Bearbeiten geöffnet wird, landet nur dann in einer Einstellungsdatei, wenn Sie sie ändern. Es gibt keine Liste zuletzt geöffneter Dokumente und keinen Zwischenspeicher. +Es ist ein leichter Editor und kein Präsentationsprogramm. Ein Foliensatz öffnet sich zum Lesen; der Bearbeitungsmodus, im Menü Bearbeiten oder mit Strg+E, lässt Sie eine Form wählen und dorthin ziehen, wo sie hingehört, ihre Größe an einer Ecke ändern, auf ihren Text klicken und ihn an Ort und Stelle ändern, ihn fett, kursiv, unterstrichen oder durchgestrichen formatieren, alles mit Rückgängig zurücknehmen und speichern. Eine Folie oder eine Form hinzufügen, anders formatieren oder eine Masterfolie ändern: dafür haben Sie eine Bürosuite. Es wird nichts geschrieben, bis Sie speichern, und dann nur die Datei, die Sie geöffnet haben, oder die, die Sie benannt haben; keine temporäre Datei bleibt daneben, keine Sicherungskopie. Die eine Einstellung, die die Anwendung sich merkt, ob ein Dokument zum Bearbeiten geöffnet wird, landet nur dann in einer Einstellungsdatei, wenn Sie sie ändern. Es gibt keine Liste zuletzt geöffneter Dokumente und keinen Zwischenspeicher. Jede Folie wird in der Größe gezeichnet, die das Dokument vorgibt. Hinter dem Text und den Bildern der Folie liegt die Masterfolie: der Grund, den sie füllt, und die Verzierungen, die eine Vorlage auf jede Folie legt, und das ist das meiste von dem, was einen Foliensatz nach seiner Vorlage aussehen lässt. Die Formen werden aus der Geometrie gezeichnet, die das Dokument angibt, statt zu Rechtecken angenähert zu werden: Pfade, benutzerdefinierte Formen mit ihren eigenen Formeln, Verbinder zwischen den Formen, die sie verknüpfen, und die Beschriftung, die eine Form trägt. Die Notizen stehen unter der Folie, eine Liste der Folien daneben. @@ -383,6 +389,7 @@ features = [ "Einfarbige, lineare und axiale Verlaufsfüllungen sowie die Bilder, die eine Folie rahmt", "Die Notizen unter jeder Folie und eine Liste der Folien daneben", "Eine Form wählen und ziehen, an einer Ecke in der Größe ändern oder auf ihren Text klicken und ihn an Ort und Stelle ändern, und alles mit Rückgängig zurücknehmen", + "Den Text einer Form fett, kursiv, unterstrichen oder durchgestrichen formatieren", "Speichert über die geöffnete Datei oder anderswohin und verweigert ein Speichern, das nicht als das Dokument im Fenster zurückgelesen würde", "Was Sie nicht angefasst haben, wird so zurückgeschrieben, wie es gelesen wurde, Element für Element, Masterfolie und Verzierungen der Vorlage eingeschlossen", "Schreibt sonst nichts: keine temporäre Datei neben Ihrem Dokument, keine Sicherungskopie, kein Zwischenspeicher; eine Einstellung in einer Einstellungsdatei, nur wenn Sie sie ändern",