Merge #267 stack (main, clipboard pane, table styles, Manage Styles, paste pictures, Asian typography, Group) into #280 (Shape Effects)
#280 now builds on #267's object model: selected_all() uses InlineObject::is_drawing (groups included); group members carry effects everywhere #267 handles them (docx writer's shared wsp() writes a:effectLst for standalone shapes and group members alike, the reader's read_wsp reads it for both, layout's float_items/display draw members with their effects); the groups_round_trip test gives a member a shadow and soft edges and checks they come back. shape.effects also applies to shapes added to the selection with Shift+click (#267's also_selected). Serbian rows for the Shape Effects strings; parity regenerated (390/431) and floor raised to 90.4%. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f1b9079911
|
|
@ -4865,6 +4865,7 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
|||
name = "wordcraft"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"eframe",
|
||||
"egui",
|
||||
"fmv-macos-events",
|
||||
|
|
|
|||
12
ROADMAP.md
12
ROADMAP.md
|
|
@ -8,8 +8,8 @@ and agent control. This file tracks where we are honestly. Generated numbers com
|
|||
|
||||
| Measure | Value |
|
||||
|---|---|
|
||||
| Commands (every action, scriptable by CLI/MCP/control channel) | **422** |
|
||||
| Feature catalog coverage (Word ribbon/menu features with a command) | **378 / 428 (88%)** |
|
||||
| Commands (every action, scriptable by CLI/MCP/control channel) | **431** |
|
||||
| Feature catalog coverage (Word ribbon/menu features with a command) | **390 / 431 (90%)** |
|
||||
| **Estimated real feature parity** (depth and fidelity, not just a command) | **~62%** |
|
||||
| **Distance to alpha** | **~85% of the way; ≈20–25 h of Claude wall-clock work** |
|
||||
| **Distance to 100% parity** | **≈120–150 h of Claude Opus 5.5 wall-clock work** (with parallel agents) |
|
||||
|
|
@ -47,7 +47,7 @@ the icon art).
|
|||
|
||||
| Area | Status | Parity |
|
||||
|---|---|---|
|
||||
| Typing, selection, clipboard, undo, find/replace | Solid; IME, autocorrect, smart quotes, list autoformat | 85% |
|
||||
| Typing, selection, clipboard, undo, find/replace | Solid; IME, autocorrect, smart quotes, list autoformat, column (block) selection | 85% |
|
||||
| Character & paragraph formatting | Nearly all properties; Font/Paragraph dialogs | 85% |
|
||||
| Styles (gallery, pane, create/modify, style sets, themes) | Good | 75% |
|
||||
| Lists (bullets, numbering, multilevel, restart, set value) | Good | 75% |
|
||||
|
|
@ -74,8 +74,8 @@ the icon art).
|
|||
|
||||
1. **Alpha blockers** (≈20–25 h): DOCX fidelity corpus, footnote continuation, column balancing,
|
||||
native printing, autosave soak test, first signed release.
|
||||
2. **Objects** (≈12 h): tight/through (contour) wrap, rotation handles on the canvas,
|
||||
grouping, z-order polish, track-changes balloons and formatting revisions.
|
||||
2. **Objects** (≈10 h): tight/through (contour) wrap, rotation handles on the canvas,
|
||||
moving a multi-selection together, z-order polish, track-changes balloons and formatting revisions.
|
||||
3. **Draw tab / ink** (≈15 h).
|
||||
4. **Charts (own renderer) and SmartArt-style diagrams** (≈20 h).
|
||||
5. **Equation editor** — done: OMML read/write, 2D layout, in-place editing with Word's Equation tab (structures, symbols, built-ins, Unicode/LaTeX input, build-up, Math AutoCorrect, `#` numbers, structure commands). Remaining: line breaking of long display equations, ink equations (≈4 h).
|
||||
|
|
@ -102,7 +102,7 @@ the icon art).
|
|||
| M8 | View | mostly done |
|
||||
| M9 | Mailings | done (first version) |
|
||||
| M10 | File/Backstage | mostly done; native printing missing |
|
||||
| M11 | Draw + objects | text wrap and text boxes done; ink, rotation, grouping missing |
|
||||
| M11 | Draw + objects | text wrap, text boxes and grouping done; ink, rotation missing |
|
||||
| M12 | Formats breadth (PDF, ODT, RTF, HTML, MD, TXT) | **done** (first versions) |
|
||||
| M13 | Performance budgets | on track (1.4 ms relayout) |
|
||||
| M14 | 1.0 polish, packaging, signing | pipeline written; waiting on remote and secrets |
|
||||
|
|
|
|||
|
|
@ -28,4 +28,4 @@ rfd = { workspace = true }
|
|||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File"] }
|
||||
|
|
|
|||
|
|
@ -44,6 +44,9 @@ pub fn start() {
|
|||
log::info!("wordcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
|
||||
}
|
||||
let inbox: Inbox = Inbox::default();
|
||||
if let Err(e) = listen_for_pasted_pictures(inbox.clone(), cc.egui_ctx.clone()) {
|
||||
log::error!("pasting pictures is unavailable: {e}");
|
||||
}
|
||||
let doc = if query().contains("sample") { wordcraft_engine::sample::sample_document() } else { wordcraft_doc::Document::new() };
|
||||
let mut app = WordApp::new(Session::new(doc), services(inbox.clone(), cc.egui_ctx.clone(), dirty));
|
||||
app.autosave = false;
|
||||
|
|
@ -100,6 +103,52 @@ impl eframe::App for WebShell {
|
|||
}
|
||||
}
|
||||
|
||||
/// Largest picture pasted from the browser's clipboard (as `insert.picture` allows).
|
||||
const MAX_PASTED_BYTES: f64 = (200u32 << 20) as f64;
|
||||
/// Most pictures one paste inserts.
|
||||
const MAX_PASTED_PICTURES: u32 = 20;
|
||||
|
||||
/// Pasting a picture (#45): eframe's paste handler only reads text, so a clipboard holding just
|
||||
/// pictures (a screenshot, a browser's Copy Image) is read here and the pictures arrive through
|
||||
/// the inbox like dropped ones. A clipboard with text is left to eframe.
|
||||
fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), String> {
|
||||
let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
|
||||
let on_paste = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::ClipboardEvent)>::new(move |e: web_sys::ClipboardEvent| {
|
||||
let Some(data) = e.clipboard_data() else { return };
|
||||
if !data.get_data("text").unwrap_or_default().is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(files) = data.files() else { return };
|
||||
for i in 0..files.length().min(MAX_PASTED_PICTURES) {
|
||||
let Some(file) = files.get(i) else { continue };
|
||||
if !file.type_().starts_with("image/") {
|
||||
continue;
|
||||
}
|
||||
if file.size() > MAX_PASTED_BYTES {
|
||||
log::warn!("pasted picture is larger than 200 MB; not inserted");
|
||||
continue;
|
||||
}
|
||||
let inbox = inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
|
||||
Ok(buf) => {
|
||||
let bytes = js_sys::Uint8Array::new(&buf).to_vec();
|
||||
// The inbox inserts files named like pictures; the engine reads the format from the bytes.
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(("pasted.png".into(), bytes));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::error!("couldn't read the pasted picture: {e:?}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
document.add_event_listener_with_callback("paste", on_paste.as_ref().unchecked_ref()).map_err(|e| format!("{e:?}"))?;
|
||||
// The listener lives as long as the page.
|
||||
on_paste.forget();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `dirty` is the flag the `beforeunload` guard reads ([`guard_unload`]).
|
||||
fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
|
|
|
|||
|
|
@ -37,5 +37,8 @@ rfd = { workspace = true }
|
|||
# Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94);
|
||||
# rfd itself already depends on it.
|
||||
pollster = "0.4"
|
||||
# Pictures and copied files on the system clipboard (#45); egui's paste only carries text. Already
|
||||
# in the build through egui-winit, with the same features.
|
||||
arboard = { workspace = true, features = ["image-data"] }
|
||||
# The control server's random key.
|
||||
getrandom = "0.3"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ mod logging;
|
|||
use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::{
|
||||
Services, UiState, WordApp,
|
||||
paste_picture::ClipboardPicture,
|
||||
window_geometry::{WindowGeometry, take_rescue},
|
||||
};
|
||||
|
||||
|
|
@ -118,9 +119,23 @@ fn save_prefs(app: &WordApp) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Native file dialogs, shown without blocking the window (`file_dialogs`, #94).
|
||||
/// Native file dialogs, shown without blocking the window (`file_dialogs`, #94), and pictures on
|
||||
/// the system clipboard (#45).
|
||||
fn services(file_dialog: file_dialogs::Hook) -> Services {
|
||||
Services { file_dialog: Some(file_dialog), ..Default::default() }
|
||||
Services { file_dialog: Some(file_dialog), clipboard_picture: Some(Box::new(clipboard_picture)), ..Default::default() }
|
||||
}
|
||||
|
||||
/// The picture on the system clipboard (#45): copied files, else image pixels. The UI checks the
|
||||
/// sizes before inserting anything.
|
||||
fn clipboard_picture() -> Option<ClipboardPicture> {
|
||||
let mut clipboard = arboard::Clipboard::new().map_err(|e| log::warn!("clipboard: {e}")).ok()?;
|
||||
if let Ok(files) = clipboard.get().file_list()
|
||||
&& !files.is_empty()
|
||||
{
|
||||
return Some(ClipboardPicture::Files(files));
|
||||
}
|
||||
let image = clipboard.get_image().ok()?;
|
||||
Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() })
|
||||
}
|
||||
|
||||
/// Window, Dock and taskbar icon.
|
||||
|
|
|
|||
|
|
@ -74,12 +74,83 @@ fn each_para_mut(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph))
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether a paragraph anywhere in `b` (also inside tables) uses paragraph or character style
|
||||
/// `id` (depth-limited like `each_para`). Table styles are the engine's `table.deleteStyle`'s job.
|
||||
fn uses_style(b: &Block, id: &str, depth: usize) -> bool {
|
||||
let is = |s: &Option<String>| s.as_deref() == Some(id);
|
||||
match b {
|
||||
Block::Para(p) => is(&p.props.style) || is(&p.mark.style) || p.runs.iter().any(|r| is(&r.props.style)),
|
||||
Block::Table(t) if depth < 16 => {
|
||||
t.rows.iter().flat_map(|r| r.cells.iter()).flat_map(|c| c.blocks.iter()).any(|cb| uses_style(cb, id, depth + 1))
|
||||
}
|
||||
Block::Table(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Point every paragraph and run in `b` that uses style `from` at `to`; returns how many
|
||||
/// paragraphs changed.
|
||||
fn restyle_block(b: &mut Block, from: &str, to: &Option<String>, depth: usize) -> usize {
|
||||
let fix = |s: &mut Option<String>| {
|
||||
if s.as_deref() == Some(from) {
|
||||
s.clone_from(to);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
match b {
|
||||
Block::Para(p) => {
|
||||
let mut changed = fix(&mut p.props.style);
|
||||
changed |= fix(&mut p.mark.style);
|
||||
for r in &mut p.runs {
|
||||
changed |= fix(&mut r.props.style);
|
||||
}
|
||||
if changed {
|
||||
p.normalize();
|
||||
p.touch();
|
||||
}
|
||||
usize::from(changed)
|
||||
}
|
||||
Block::Table(t) => {
|
||||
let mut n = 0;
|
||||
if depth < 16 {
|
||||
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||
for cb in &mut cell.blocks {
|
||||
if uses_style(cb, from, depth + 1) {
|
||||
n += restyle_block(Arc::make_mut(cb), from, to, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text box story ids of a paragraph's shapes.
|
||||
fn box_ids(p: &Paragraph) -> impl Iterator<Item = u32> + '_ {
|
||||
p.objects.iter().filter_map(|o| if let InlineObject::Shape { story: Some(id), .. } = o { Some(*id) } else { None })
|
||||
p.objects.iter().flat_map(InlineObject::text_boxes)
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Point every paragraph and run that uses paragraph or character style `from` at `to`
|
||||
/// (`None`: the default style), in every story, tables included. Returns how many paragraphs
|
||||
/// changed. (Tables' own table styles are retargeted by the engine's table style deletion.)
|
||||
pub fn restyle(&mut self, from: &str, to: Option<String>) -> usize {
|
||||
let stories: Vec<crate::StoryRef> =
|
||||
std::iter::once(crate::StoryRef::Body).chain(self.parts.keys().map(|k| crate::StoryRef::Part(*k))).collect();
|
||||
let mut n = 0;
|
||||
for s in stories {
|
||||
let Ok(bl) = self.story_mut(s) else { continue };
|
||||
for b in bl.iter_mut() {
|
||||
if uses_style(b, from, 0) {
|
||||
n += restyle_block(Arc::make_mut(b), from, &to, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Insert plain text at `pos` (`\n` inside `s` is a manual line break; use
|
||||
/// [`Document::split_paragraph`] for paragraph breaks). Returns the position after it.
|
||||
pub fn insert_text(&mut self, pos: &Pos, s: &str, props: &CharProps) -> Result<Pos> {
|
||||
|
|
@ -320,11 +391,11 @@ impl Document {
|
|||
each_para_mut(b, 0, &mut |p| {
|
||||
let mut changed = false;
|
||||
for o in p.objects.iter_mut() {
|
||||
if let InlineObject::Shape { story, .. } = o
|
||||
&& let Some(old) = *story
|
||||
{
|
||||
*story = self.adopt_text_box(old, parts, depth, budget);
|
||||
changed = true;
|
||||
for story in o.text_box_slots() {
|
||||
if let Some(old) = *story {
|
||||
*story = self.adopt_text_box(old, parts, depth, budget);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
|
|
|
|||
|
|
@ -588,7 +588,7 @@ impl Document {
|
|||
for path in self.para_paths(story) {
|
||||
let Some(p) = self.para(story, &path) else { continue };
|
||||
for off in p.object_offsets() {
|
||||
if matches!(p.object_at(off), Some(InlineObject::Shape { story: Some(id), .. }) if *id == part) {
|
||||
if p.object_at(off).is_some_and(|o| o.text_boxes().contains(&part)) {
|
||||
return Some(Pos { story, path, off: off + para::OBJ.len_utf8() });
|
||||
}
|
||||
}
|
||||
|
|
@ -692,9 +692,8 @@ impl Document {
|
|||
/// document order. Headers, footers, comments and stories nothing points at aren't included.
|
||||
pub fn counted_stories(&self) -> Vec<StoryRef> {
|
||||
self.reachable(vec![StoryRef::Body], &|o| match o {
|
||||
InlineObject::Shape { story: Some(id), .. } => Some((*id, &[PartKind::TextBox])),
|
||||
InlineObject::NoteRef { id, .. } => Some((*id, &[PartKind::Footnote, PartKind::Endnote])),
|
||||
_ => None,
|
||||
InlineObject::NoteRef { id, .. } => vec![(*id, &[PartKind::Footnote, PartKind::Endnote][..])],
|
||||
o => o.text_boxes().into_iter().map(|id| (id, &[PartKind::TextBox][..])).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -708,10 +707,7 @@ impl Document {
|
|||
let roots = std::iter::once(StoryRef::Body)
|
||||
.chain(self.parts.iter().filter(|(_, p)| p.kind != PartKind::TextBox).map(|(id, _)| StoryRef::Part(*id)))
|
||||
.collect();
|
||||
let live = self.reachable(roots, &|o| match o {
|
||||
InlineObject::Shape { story: Some(id), .. } => Some((*id, &[PartKind::TextBox])),
|
||||
_ => None,
|
||||
});
|
||||
let live = self.reachable(roots, &|o| o.text_boxes().into_iter().map(|id| (id, &[PartKind::TextBox][..])).collect());
|
||||
let before = self.parts.len();
|
||||
self.parts.retain(|id, p| p.kind != PartKind::TextBox || live.contains(&StoryRef::Part(*id)));
|
||||
before - self.parts.len()
|
||||
|
|
@ -757,13 +753,16 @@ impl Document {
|
|||
for b in blocks {
|
||||
edit::each_para(b, 0, &mut |p| {
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
if f(p, k)
|
||||
&& let InlineObject::Shape { story: Some(id), .. } = o
|
||||
&& let Some(part) = self.parts.get(id).filter(|p| p.kind == PartKind::TextBox)
|
||||
&& budget.enter(*id)
|
||||
{
|
||||
self.walk_objects(&part.blocks, budget, f);
|
||||
budget.leave();
|
||||
if !f(p, k) {
|
||||
continue;
|
||||
}
|
||||
for id in o.text_boxes() {
|
||||
if let Some(part) = self.parts.get(&id).filter(|p| p.kind == PartKind::TextBox)
|
||||
&& budget.enter(id)
|
||||
{
|
||||
self.walk_objects(&part.blocks, budget, f);
|
||||
budget.leave();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -772,7 +771,7 @@ impl Document {
|
|||
|
||||
/// `roots`, then every story their objects lead to (`follow`: an object's story id and the
|
||||
/// part kinds that count), transitively, in the order found.
|
||||
fn reachable(&self, roots: Vec<StoryRef>, follow: &dyn Fn(&InlineObject) -> Option<(u32, &'static [PartKind])>) -> Vec<StoryRef> {
|
||||
fn reachable(&self, roots: Vec<StoryRef>, follow: &dyn Fn(&InlineObject) -> Vec<(u32, &'static [PartKind])>) -> Vec<StoryRef> {
|
||||
let mut out = roots;
|
||||
let mut seen: std::collections::BTreeSet<StoryRef> = out.iter().copied().collect();
|
||||
let mut i = 0;
|
||||
|
|
@ -781,7 +780,7 @@ impl Document {
|
|||
let mut found = Vec::new();
|
||||
for b in self.story(s).into_iter().flatten() {
|
||||
edit::each_para(b, 0, &mut |p| {
|
||||
found.extend(p.objects.iter().filter_map(follow));
|
||||
found.extend(p.objects.iter().flat_map(follow));
|
||||
});
|
||||
}
|
||||
for (id, kinds) in found {
|
||||
|
|
|
|||
|
|
@ -217,6 +217,20 @@ pub enum InlineObject {
|
|||
#[serde(default, skip_serializing_if = "crate::effects::ShapeEffects::is_empty")]
|
||||
effects: crate::effects::ShapeEffects,
|
||||
},
|
||||
/// Pictures, shapes and text boxes grouped into one object (Layout › Arrange › Group): it
|
||||
/// moves, wraps and resizes as one. Its members are laid out in the group's own coordinate
|
||||
/// space, `ch_w` × `ch_h`, which is stretched over the group's `w` × `h` (DrawingML's
|
||||
/// `a:chExt` and `a:ext`), so resizing the group scales them.
|
||||
Group {
|
||||
w: f32,
|
||||
h: f32,
|
||||
#[serde(default)]
|
||||
float: Float,
|
||||
/// Size of the members' coordinate space (their offsets and sizes are in it).
|
||||
ch_w: f32,
|
||||
ch_h: f32,
|
||||
children: Vec<GroupChild>,
|
||||
},
|
||||
/// A field: `instr` is the field code (`PAGE`, `NUMPAGES`, `DATE \@ "M/d/yyyy"`, `TOC \o "1-3"`…);
|
||||
/// `result` the cached display text.
|
||||
Field {
|
||||
|
|
@ -276,7 +290,93 @@ pub enum InlineObject {
|
|||
},
|
||||
}
|
||||
|
||||
/// One member of an [`InlineObject::Group`]: a picture or shape (its own `w`, `h` and story), at
|
||||
/// `x`, `y` in the group's coordinate space. Its `float` is unused.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GroupChild {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub obj: InlineObject,
|
||||
}
|
||||
|
||||
/// Most members a group keeps (hostile files).
|
||||
pub const MAX_GROUP_CHILDREN: usize = 1000;
|
||||
|
||||
impl InlineObject {
|
||||
/// A picture, shape, text box or group: something drawn with a frame, sized and wrapped.
|
||||
pub fn is_drawing(&self) -> bool {
|
||||
matches!(self, InlineObject::Image { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. })
|
||||
}
|
||||
/// A drawing's size and placement.
|
||||
pub fn frame(&self) -> Option<(f32, f32, &Float)> {
|
||||
match self {
|
||||
InlineObject::Image { w, h, float, .. } | InlineObject::Shape { w, h, float, .. } | InlineObject::Group { w, h, float, .. } => {
|
||||
Some((*w, *h, float))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// A drawing's placement, to change.
|
||||
pub fn float_mut(&mut self) -> Option<&mut Float> {
|
||||
match self {
|
||||
InlineObject::Image { float, .. } | InlineObject::Shape { float, .. } | InlineObject::Group { float, .. } => Some(float),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Resize a drawing (a group's members scale with it).
|
||||
pub fn set_size(&mut self, nw: f32, nh: f32) {
|
||||
if let InlineObject::Image { w, h, .. } | InlineObject::Shape { w, h, .. } | InlineObject::Group { w, h, .. } = self {
|
||||
*w = nw;
|
||||
*h = nh;
|
||||
}
|
||||
}
|
||||
/// The text box story this object shows, if it is a text box.
|
||||
pub fn text_box(&self) -> Option<u32> {
|
||||
match self {
|
||||
InlineObject::Shape { story, .. } => *story,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The text box stories this object shows: a text box's, or its group members'.
|
||||
pub fn text_boxes(&self) -> Vec<u32> {
|
||||
match self {
|
||||
InlineObject::Shape { story: Some(id), .. } => vec![*id],
|
||||
InlineObject::Group { children, .. } => children.iter().filter_map(|c| c.obj.text_box()).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
/// The text box story slots of this object and its group members, to repoint.
|
||||
pub fn text_box_slots(&mut self) -> Vec<&mut Option<u32>> {
|
||||
match self {
|
||||
InlineObject::Shape { story, .. } => vec![story],
|
||||
InlineObject::Group { children, .. } => children
|
||||
.iter_mut()
|
||||
.filter_map(|c| match &mut c.obj {
|
||||
InlineObject::Shape { story, .. } => Some(story),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
/// A group's members placed in a `w` × `h` box at (`x`, `y`): each member's rectangle
|
||||
/// (x, y, w, h) and the member. Empty for anything else.
|
||||
pub fn group_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<([f32; 4], &InlineObject)> {
|
||||
let InlineObject::Group { ch_w, ch_h, children, .. } = self else { return Vec::new() };
|
||||
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
|
||||
let sx = if fin(*ch_w) > 0.0 { fin(w) / ch_w } else { 1.0 };
|
||||
let sy = if fin(*ch_h) > 0.0 { fin(h) / ch_h } else { 1.0 };
|
||||
children
|
||||
.iter()
|
||||
.take(MAX_GROUP_CHILDREN)
|
||||
.filter_map(|c| {
|
||||
let (cw, chh, _) = c.obj.frame()?;
|
||||
let r = [x + fin(c.x) * sx, y + fin(c.y) * sy, (fin(cw) * sx).clamp(0.0, 4000.0), (fin(chh) * sy).clamp(0.0, 4000.0)];
|
||||
(!matches!(c.obj, InlineObject::Group { .. })).then_some((r, &c.obj))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Zero-width markers don't take part in layout.
|
||||
pub fn is_marker(&self) -> bool {
|
||||
matches!(
|
||||
|
|
@ -291,7 +391,7 @@ impl InlineObject {
|
|||
}
|
||||
pub fn is_floating(&self) -> bool {
|
||||
match self {
|
||||
InlineObject::Image { float, .. } | InlineObject::Shape { float, .. } => float.wrap != Wrap::Inline,
|
||||
InlineObject::Image { float, .. } | InlineObject::Shape { float, .. } | InlineObject::Group { float, .. } => float.wrap != Wrap::Inline,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -622,13 +622,34 @@ pub struct ParaProps {
|
|||
/// Drop cap: lines to drop (0 = none) — applies to the first character(s).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub drop_cap: Option<u8>,
|
||||
/// Asian typography (`w:kinsoku`): East Asian line-breaking rules — no line starts with
|
||||
/// closing punctuation such as 、。」 or ends with opening punctuation such as 「(.
|
||||
/// Unset = on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub kinsoku: Option<bool>,
|
||||
/// `w:wordWrap`: Latin words wrap whole; `false` lets them break at any character (Word's
|
||||
/// "Allow Latin text to wrap in the middle of a word"). Unset = on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub word_wrap: Option<bool>,
|
||||
/// `w:overflowPunct`: punctuation may hang past the line end. Unset = on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub overflow_punct: Option<bool>,
|
||||
/// `w:topLinePunct`: compress punctuation at the start of a line. Unset = off.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_line_punct: Option<bool>,
|
||||
/// `w:autoSpaceDE`: automatic space between Asian and Latin text. Unset = on.
|
||||
#[serde(rename = "autoSpaceDE", skip_serializing_if = "Option::is_none")]
|
||||
pub auto_space_de: Option<bool>,
|
||||
/// `w:autoSpaceDN`: automatic space between Asian text and numbers. Unset = on.
|
||||
#[serde(rename = "autoSpaceDN", skip_serializing_if = "Option::is_none")]
|
||||
pub auto_space_dn: Option<bool>,
|
||||
}
|
||||
|
||||
impl ParaProps {
|
||||
pub fn overlay(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, space_before, space_after, line_spacing,
|
||||
contextual_spacing, keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, tabs, shading, borders,
|
||||
suppress_hyphens, suppress_line_numbers, bidi, drop_cap);
|
||||
suppress_hyphens, suppress_line_numbers, bidi, drop_cap, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
|
||||
}
|
||||
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
|
||||
self.overlay(patch);
|
||||
|
|
|
|||
|
|
@ -141,6 +141,14 @@ pub struct ResolvedPara {
|
|||
pub bidi: bool,
|
||||
pub drop_cap: u8,
|
||||
pub suppress_hyphens: bool,
|
||||
/// Asian typography (see `ParaProps`): East Asian line-breaking rules.
|
||||
pub kinsoku: bool,
|
||||
/// Latin words wrap whole (`false`: they may break at any character).
|
||||
pub word_wrap: bool,
|
||||
pub overflow_punct: bool,
|
||||
pub top_line_punct: bool,
|
||||
pub auto_space_de: bool,
|
||||
pub auto_space_dn: bool,
|
||||
}
|
||||
|
||||
const MAX_INDENT: f32 = 1584.0; // 22"
|
||||
|
|
@ -186,6 +194,12 @@ impl ResolvedPara {
|
|||
bidi: p.bidi.unwrap_or(false),
|
||||
drop_cap: p.drop_cap.unwrap_or(0).min(10),
|
||||
suppress_hyphens: p.suppress_hyphens.unwrap_or(false),
|
||||
kinsoku: p.kinsoku.unwrap_or(true),
|
||||
word_wrap: p.word_wrap.unwrap_or(true),
|
||||
overflow_punct: p.overflow_punct.unwrap_or(true),
|
||||
top_line_punct: p.top_line_punct.unwrap_or(false),
|
||||
auto_space_de: p.auto_space_de.unwrap_or(true),
|
||||
auto_space_dn: p.auto_space_dn.unwrap_or(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,27 +52,52 @@ pub struct TableStyleParts {
|
|||
pub cell_margins: Option<[f32; 4]>,
|
||||
pub header_fill: Option<Rgb>,
|
||||
pub header_chr: CharProps,
|
||||
/// Cell borders of the header row (`firstRow` region's `w:tcBorders`): `between` is the
|
||||
/// border between header rows, `inside_v` between its cells.
|
||||
pub header_borders: Option<Borders>,
|
||||
pub band_fill: Option<Rgb>,
|
||||
/// Character formatting of the odd row bands (`band1Horz` region).
|
||||
pub band_chr: CharProps,
|
||||
/// Cell borders of the odd row bands (`band1Horz` region's `w:tcBorders`).
|
||||
pub band_borders: Option<Borders>,
|
||||
/// Rows per band (`w:tblStyleRowBandSize`); one when unset.
|
||||
pub band_size: Option<u32>,
|
||||
pub first_col_chr: CharProps,
|
||||
pub total_chr: CharProps,
|
||||
pub total_border_top: Option<Border>,
|
||||
/// Shading and cell borders of the first column (`firstCol` region).
|
||||
pub first_col_fill: Option<Rgb>,
|
||||
pub first_col_borders: Option<Borders>,
|
||||
/// The last column (`lastCol` region).
|
||||
pub last_col_fill: Option<Rgb>,
|
||||
pub last_col_chr: CharProps,
|
||||
pub last_col_borders: Option<Borders>,
|
||||
/// Shading and cell borders of the total (last) row (`lastRow` region); a top edge left
|
||||
/// unset falls back to `total_border_top`.
|
||||
pub total_fill: Option<Rgb>,
|
||||
pub total_borders: Option<Borders>,
|
||||
/// The odd column bands (`band1Vert` region).
|
||||
pub col_band_fill: Option<Rgb>,
|
||||
pub col_band_chr: CharProps,
|
||||
pub col_band_borders: Option<Borders>,
|
||||
}
|
||||
|
||||
impl TableStyleParts {
|
||||
/// Apply `patch` (a style further down a based-on chain) over these parts: what it sets wins,
|
||||
/// the rest is inherited; borders merge edge by edge.
|
||||
pub fn overlay(&mut self, patch: &TableStyleParts) {
|
||||
self.borders = match (self.borders, patch.borders) {
|
||||
(Some(b), Some(p)) => Some(Borders {
|
||||
top: p.top.or(b.top),
|
||||
left: p.left.or(b.left),
|
||||
bottom: p.bottom.or(b.bottom),
|
||||
right: p.right.or(b.right),
|
||||
between: p.between.or(b.between),
|
||||
inside_v: p.inside_v.or(b.inside_v),
|
||||
}),
|
||||
let merge = |b: Option<Borders>, p: Option<Borders>| match (b, p) {
|
||||
(Some(mut b), Some(p)) => {
|
||||
b.overlay(&p);
|
||||
Some(b)
|
||||
}
|
||||
(b, p) => p.or(b),
|
||||
};
|
||||
self.borders = merge(self.borders, patch.borders);
|
||||
self.header_borders = merge(self.header_borders, patch.header_borders);
|
||||
self.band_borders = merge(self.band_borders, patch.band_borders);
|
||||
self.band_size = patch.band_size.or(self.band_size);
|
||||
self.band_chr.overlay(&patch.band_chr);
|
||||
self.fill = patch.fill.or(self.fill);
|
||||
self.cell_margins = patch.cell_margins.or(self.cell_margins);
|
||||
self.header_fill = patch.header_fill.or(self.header_fill);
|
||||
|
|
@ -81,6 +106,16 @@ impl TableStyleParts {
|
|||
self.first_col_chr.overlay(&patch.first_col_chr);
|
||||
self.total_chr.overlay(&patch.total_chr);
|
||||
self.total_border_top = patch.total_border_top.or(self.total_border_top);
|
||||
self.first_col_fill = patch.first_col_fill.or(self.first_col_fill);
|
||||
self.first_col_borders = merge(self.first_col_borders, patch.first_col_borders);
|
||||
self.last_col_fill = patch.last_col_fill.or(self.last_col_fill);
|
||||
self.last_col_chr.overlay(&patch.last_col_chr);
|
||||
self.last_col_borders = merge(self.last_col_borders, patch.last_col_borders);
|
||||
self.total_fill = patch.total_fill.or(self.total_fill);
|
||||
self.total_borders = merge(self.total_borders, patch.total_borders);
|
||||
self.col_band_fill = patch.col_band_fill.or(self.col_band_fill);
|
||||
self.col_band_chr.overlay(&patch.col_band_chr);
|
||||
self.col_band_borders = merge(self.col_band_borders, patch.col_band_borders);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -427,6 +462,25 @@ impl StyleSheet {
|
|||
None => self.styles.push(st),
|
||||
}
|
||||
}
|
||||
/// Remove style `id` and return it. Styles based on it are rebased onto its own base, and
|
||||
/// `next` / `linked` references to it are dropped. Text that uses it is the document's to
|
||||
/// retarget ([`crate::Document::restyle`]).
|
||||
pub fn remove(&mut self, id: &str) -> Option<Style> {
|
||||
let i = self.styles.iter().position(|s| s.id == id)?;
|
||||
let gone = self.styles.remove(i);
|
||||
for s in &mut self.styles {
|
||||
if s.based_on.as_deref() == Some(id) {
|
||||
s.based_on = gone.based_on.clone().filter(|b| *b != s.id);
|
||||
}
|
||||
if s.next.as_deref() == Some(id) {
|
||||
s.next = None;
|
||||
}
|
||||
if s.linked.as_deref() == Some(id) {
|
||||
s.linked = None;
|
||||
}
|
||||
}
|
||||
Some(gone)
|
||||
}
|
||||
/// An id not used yet, derived from a display name.
|
||||
pub fn new_id(&self, name: &str) -> String {
|
||||
let base: String = name.chars().filter(|c| c.is_alphanumeric()).collect();
|
||||
|
|
|
|||
|
|
@ -444,9 +444,11 @@ impl Reader<'_> {
|
|||
}
|
||||
t.fill = tp.child("w:shd").and_then(props::shd_fill);
|
||||
t.cell_margins = tp.child("w:tblCellMar").map(props::margins);
|
||||
t.band_size = tp.child_val("w:tblStyleRowBandSize").and_then(u32_of).filter(|n| *n > 0).map(|n| n.min(1000));
|
||||
}
|
||||
for c in s.children("w:tblStylePr") {
|
||||
for c in s.children("w:tblStylePr").take(64) {
|
||||
let fill = c.child("w:tcPr").and_then(|p| p.child("w:shd")).and_then(props::shd_fill);
|
||||
let cell_borders = c.child("w:tcPr").and_then(|p| p.child("w:tcBorders")).map(props::borders);
|
||||
let chr = c.child("w:rPr").map(|r| self.pc.rpr(r)).unwrap_or_default();
|
||||
match c.attr("w:type") {
|
||||
Some("wholeTable") => {
|
||||
|
|
@ -464,12 +466,35 @@ impl Reader<'_> {
|
|||
Some("firstRow") => {
|
||||
t.header_fill = fill;
|
||||
t.header_chr = chr;
|
||||
t.header_borders = cell_borders;
|
||||
}
|
||||
Some("band1Horz") => {
|
||||
t.band_fill = fill;
|
||||
t.band_chr = chr;
|
||||
t.band_borders = cell_borders;
|
||||
}
|
||||
Some("firstCol") => {
|
||||
t.first_col_chr = chr;
|
||||
t.first_col_fill = fill;
|
||||
t.first_col_borders = cell_borders;
|
||||
}
|
||||
Some("lastCol") => {
|
||||
t.last_col_chr = chr;
|
||||
t.last_col_fill = fill;
|
||||
t.last_col_borders = cell_borders;
|
||||
}
|
||||
Some("band1Horz") => t.band_fill = fill,
|
||||
Some("firstCol") => t.first_col_chr = chr,
|
||||
Some("lastRow") => {
|
||||
t.total_chr = chr;
|
||||
t.total_border_top = c.child("w:tcPr").and_then(|p| p.child("w:tcBorders")).and_then(|b| b.child("w:top")).map(props::border);
|
||||
t.total_fill = fill;
|
||||
// The top rule is kept on its own (as built-in styles have it); the other edges
|
||||
// are the region's borders.
|
||||
t.total_border_top = cell_borders.and_then(|b| b.top);
|
||||
t.total_borders = cell_borders.map(|b| wordcraft_doc::props::Borders { top: None, ..b }).filter(|b| *b != Default::default());
|
||||
}
|
||||
Some("band1Vert") => {
|
||||
t.col_band_fill = fill;
|
||||
t.col_band_chr = chr;
|
||||
t.col_band_borders = cell_borders;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,12 @@ impl PropCtx {
|
|||
"w:suppressAutoHyphens" => p.suppress_hyphens = Some(on_off(k)),
|
||||
"w:suppressLineNumbers" => p.suppress_line_numbers = Some(on_off(k)),
|
||||
"w:bidi" => p.bidi = Some(on_off(k)),
|
||||
"w:kinsoku" => p.kinsoku = Some(on_off(k)),
|
||||
"w:wordWrap" => p.word_wrap = Some(on_off(k)),
|
||||
"w:overflowPunct" => p.overflow_punct = Some(on_off(k)),
|
||||
"w:topLinePunct" => p.top_line_punct = Some(on_off(k)),
|
||||
"w:autoSpaceDE" => p.auto_space_de = Some(on_off(k)),
|
||||
"w:autoSpaceDN" => p.auto_space_dn = Some(on_off(k)),
|
||||
"w:framePr" => {
|
||||
if matches!(k.attr("w:dropCap"), Some("drop") | Some("margin")) {
|
||||
p.drop_cap = Some(k.attr("w:lines").and_then(u32_of).unwrap_or(3).clamp(1, 10) as u8);
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ fn clean_text(s: &str) -> String {
|
|||
|
||||
fn children_of_choice(ac: &El) -> Option<&El> {
|
||||
// Prefer a Choice whose requirements we understand; else the Fallback; else any Choice.
|
||||
const KNOWN: &[&str] = &["wps", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v"];
|
||||
const KNOWN: &[&str] = &["wps", "wpg", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v"];
|
||||
let ok = |c: &El| c.attr("Requires").unwrap_or("").split_whitespace().all(|r| KNOWN.contains(&r));
|
||||
ac.children("mc:Choice").find(|c| ok(c)).or_else(|| ac.child("mc:Fallback")).or_else(|| ac.child("mc:Choice"))
|
||||
}
|
||||
|
|
@ -650,19 +650,11 @@ impl Reader<'_> {
|
|||
}
|
||||
}
|
||||
let gd = c.child("a:graphic").and_then(|g| g.child("a:graphicData"))?;
|
||||
if let Some(blip) = gd.find("a:blip") {
|
||||
let media = blip.attr("r:embed").and_then(|id| self.media_for(rels, id))?;
|
||||
let mut crop = [0.0f32; 4];
|
||||
if let Some(sr) = gd.find("a:srcRect") {
|
||||
for (i, n) in ["l", "t", "r", "b"].iter().enumerate() {
|
||||
if let Some(v) = sr.attr(n).and_then(int)
|
||||
&& let Some(slot) = crop.get_mut(i)
|
||||
{
|
||||
*slot = (v.clamp(0, 100_000) as f32 / 100_000.0).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Some(InlineObject::Image { media, w, h, alt, float, crop });
|
||||
if let Some(g) = gd.child("wpg:wgp") {
|
||||
return self.read_group(sc, g, rels, w, h, float);
|
||||
}
|
||||
if gd.find("a:blip").is_some() {
|
||||
return self.read_pic(gd, rels, w, h, alt, float);
|
||||
}
|
||||
if let Some(wsp) = gd.find("wps:wsp") {
|
||||
return Some(self.read_wsp(sc, wsp, rels, w, h, float));
|
||||
|
|
@ -670,6 +662,99 @@ impl Reader<'_> {
|
|||
None
|
||||
}
|
||||
|
||||
/// A picture: the first `a:blip` in `pic` and its crop.
|
||||
fn read_pic(&mut self, pic: &El, rels: &Rels, w: f32, h: f32, alt: String, float: Float) -> Option<InlineObject> {
|
||||
let blip = pic.find("a:blip")?;
|
||||
let media = blip.attr("r:embed").and_then(|id| self.media_for(rels, id))?;
|
||||
let mut crop = [0.0f32; 4];
|
||||
if let Some(sr) = pic.find("a:srcRect") {
|
||||
for (i, n) in ["l", "t", "r", "b"].iter().enumerate() {
|
||||
if let Some(v) = sr.attr(n).and_then(int)
|
||||
&& let Some(slot) = crop.get_mut(i)
|
||||
{
|
||||
*slot = (v.clamp(0, 100_000) as f32 / 100_000.0).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(InlineObject::Image { media, w, h, alt, float, crop })
|
||||
}
|
||||
|
||||
/// A `wpg:wgp` group (`w` × `h`): its pictures, shapes and text boxes, nested groups
|
||||
/// flattened into it. `None` when it has no member we can show.
|
||||
fn read_group(&mut self, sc: &mut StoryCtx, g: &El, rels: &Rels, w: f32, h: f32, float: Float) -> Option<InlineObject> {
|
||||
let x = group_xfrm(g.child("wpg:grpSpPr"));
|
||||
// The members' space; without `a:chExt` it is the group's own size.
|
||||
let (ch_w, ch_h) = match x.ch_ext {
|
||||
Some((cw, ch)) if cw > 0.0 && ch > 0.0 => (cw, ch),
|
||||
_ => (w.max(1.0), h.max(1.0)),
|
||||
};
|
||||
let (cx, cy) = x.ch_off.unwrap_or((0.0, 0.0));
|
||||
let mut children = Vec::new();
|
||||
self.group_members(sc, g, rels, (-cx, -cy, 1.0, 1.0), 0, &mut children);
|
||||
if children.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(InlineObject::Group { w, h, float, ch_w, ch_h, children })
|
||||
}
|
||||
|
||||
/// The members of group `g` into `out`, mapped into the outermost group's space by `t`
|
||||
/// (x0, y0, sx, sy: outer = x0 + inner × sx).
|
||||
fn group_members(
|
||||
&mut self,
|
||||
sc: &mut StoryCtx,
|
||||
g: &El,
|
||||
rels: &Rels,
|
||||
t: (f32, f32, f32, f32),
|
||||
depth: usize,
|
||||
out: &mut Vec<wordcraft_doc::para::GroupChild>,
|
||||
) {
|
||||
let (x0, y0, sx, sy) = t;
|
||||
for e in g.els() {
|
||||
if out.len() >= wordcraft_doc::para::MAX_GROUP_CHILDREN {
|
||||
return;
|
||||
}
|
||||
let e = match e.name.as_str() {
|
||||
"mc:AlternateContent" => match children_of_choice(e).and_then(|c| c.els().next()) {
|
||||
Some(c) => c,
|
||||
None => continue,
|
||||
},
|
||||
_ => e,
|
||||
};
|
||||
let sppr = match e.name.as_str() {
|
||||
"wps:wsp" => e.child("wps:spPr"),
|
||||
"pic:pic" => e.child("pic:spPr"),
|
||||
"wpg:grpSp" => e.child("wpg:grpSpPr"),
|
||||
_ => continue,
|
||||
};
|
||||
let x = group_xfrm(sppr);
|
||||
let ((ox, oy), (ew, eh)) = (x.off, x.ext);
|
||||
if e.name == "wpg:grpSp" {
|
||||
if depth >= 8 {
|
||||
continue;
|
||||
}
|
||||
// Its members' space maps onto its own box.
|
||||
let (cox, coy) = x.ch_off.unwrap_or((0.0, 0.0));
|
||||
let (cw, ch) = x.ch_ext.filter(|(cw, ch)| *cw > 0.0 && *ch > 0.0).unwrap_or((ew.max(1.0), eh.max(1.0)));
|
||||
let (kx, ky) = (ew / cw, eh / ch);
|
||||
let nt = (x0 + (ox - cox * kx) * sx, y0 + (oy - coy * ky) * sy, sx * kx, sy * ky);
|
||||
self.group_members(sc, e, rels, nt, depth + 1, out);
|
||||
continue;
|
||||
}
|
||||
let max = crate::units::MAX_LEN_PT;
|
||||
let fit = |v: f32, lo: f32| wordcraft_geom::finite(v).clamp(lo, max);
|
||||
let (w, h) = (fit(ew * sx, 0.0), fit(eh * sy, 0.0));
|
||||
let obj = if e.name == "pic:pic" {
|
||||
let alt = e.find("pic:cNvPr").and_then(|p| p.attr("descr")).unwrap_or("").to_string();
|
||||
self.read_pic(e, rels, w, h, alt, Float::default())
|
||||
} else {
|
||||
Some(self.read_wsp(sc, e, rels, w, h, Float::default()))
|
||||
};
|
||||
if let Some(obj) = obj {
|
||||
out.push(wordcraft_doc::para::GroupChild { x: fit(x0 + ox * sx, -max), y: fit(y0 + oy * sy, -max), obj });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_wsp(&mut self, sc: &mut StoryCtx, wsp: &El, rels: &Rels, w: f32, h: f32, float: Float) -> InlineObject {
|
||||
let sppr = wsp.child("wps:spPr");
|
||||
let prst = sppr.and_then(|s| s.child("a:prstGeom")).and_then(|g| g.attr("prst")).unwrap_or("rect");
|
||||
|
|
@ -967,3 +1052,30 @@ fn effect_color(e: &El) -> (Option<Rgb>, f32) {
|
|||
let opacity = c.child("a:alpha").and_then(|a| a.attr("val")).and_then(int).map(|v| v.clamp(0, 100_000) as f32 / 1000.0).unwrap_or(100.0);
|
||||
(rgb, 100.0 - opacity)
|
||||
}
|
||||
|
||||
/// A DrawingML `a:xfrm` (ECMA-376 §20.1.7.5/6) in points: offset, extent and, for a group, its
|
||||
/// members' offset and extent.
|
||||
struct Xfrm {
|
||||
off: (f32, f32),
|
||||
ext: (f32, f32),
|
||||
ch_off: Option<(f32, f32)>,
|
||||
ch_ext: Option<(f32, f32)>,
|
||||
}
|
||||
|
||||
/// The `a:xfrm` in shape properties `sppr` (zeros when missing).
|
||||
fn group_xfrm(sppr: Option<&El>) -> Xfrm {
|
||||
let x = sppr.and_then(|s| s.child("a:xfrm"));
|
||||
let pair = |name: &str, a: &str, b: &str, lim: f32| {
|
||||
let e = x.and_then(|x| x.child(name))?;
|
||||
let v = |n: &str| e.attr(n).and_then(|v| measure(v, 12_700.0)).map(|v| v.clamp(-lim, lim)).unwrap_or(0.0);
|
||||
Some((v(a), v(b)))
|
||||
};
|
||||
let max = crate::units::MAX_LEN_PT;
|
||||
let pos = |p: Option<(f32, f32)>| p.map(|(a, b)| (a.max(0.0), b.max(0.0)));
|
||||
Xfrm {
|
||||
off: pair("a:off", "x", "y", max).unwrap_or((0.0, 0.0)),
|
||||
ext: pos(pair("a:ext", "cx", "cy", max)).unwrap_or((0.0, 0.0)),
|
||||
ch_off: pair("a:chOff", "x", "y", max),
|
||||
ch_ext: pos(pair("a:chExt", "cx", "cy", max)),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -622,8 +622,11 @@ fn style_xml(w: &mut W, st: &Style) {
|
|||
w.close("w:rPr");
|
||||
}
|
||||
if let Some(t) = &st.table {
|
||||
if t.borders.is_some() || t.cell_margins.is_some() {
|
||||
if t.borders.is_some() || t.cell_margins.is_some() || t.band_size.is_some() {
|
||||
w.open("w:tblPr", &[]);
|
||||
if let Some(n) = t.band_size {
|
||||
w.val("w:tblStyleRowBandSize", &n.clamp(1, 1000).to_string());
|
||||
}
|
||||
if let Some(b) = &t.borders {
|
||||
props::borders(w, "w:tblBorders", b, Some("w:insideH"), &[]);
|
||||
}
|
||||
|
|
@ -632,35 +635,49 @@ fn style_xml(w: &mut W, st: &Style) {
|
|||
}
|
||||
w.close("w:tblPr");
|
||||
}
|
||||
let cond =
|
||||
|w: &mut W, ty: &str, chr: &wordcraft_doc::CharProps, fill: Option<wordcraft_doc::Rgb>, top: Option<&wordcraft_doc::props::Border>| {
|
||||
if !props::has_rpr(chr) && fill.is_none() && top.is_none() {
|
||||
return;
|
||||
// One conditional formatting region (ECMA-376 §17.7.6): run properties, then cell borders
|
||||
// and shading.
|
||||
let cond = |w: &mut W,
|
||||
ty: &str,
|
||||
chr: &wordcraft_doc::CharProps,
|
||||
fill: Option<wordcraft_doc::Rgb>,
|
||||
borders: Option<wordcraft_doc::props::Borders>| {
|
||||
if !props::has_rpr(chr) && fill.is_none() && borders.is_none() {
|
||||
return;
|
||||
}
|
||||
w.open("w:tblStylePr", &[("w:type", ty)]);
|
||||
if props::has_rpr(chr) {
|
||||
w.open("w:rPr", &[]);
|
||||
props::rpr_inner(w, chr);
|
||||
w.close("w:rPr");
|
||||
}
|
||||
if fill.is_some() || borders.is_some() {
|
||||
w.open("w:tcPr", &[]);
|
||||
if let Some(bs) = &borders {
|
||||
props::borders(w, "w:tcBorders", bs, Some("w:insideH"), &[]);
|
||||
}
|
||||
w.open("w:tblStylePr", &[("w:type", ty)]);
|
||||
if props::has_rpr(chr) {
|
||||
w.open("w:rPr", &[]);
|
||||
props::rpr_inner(w, chr);
|
||||
w.close("w:rPr");
|
||||
if let Some(f) = fill {
|
||||
w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &f.hex())]);
|
||||
}
|
||||
if fill.is_some() || top.is_some() {
|
||||
w.open("w:tcPr", &[]);
|
||||
if let Some(b) = top {
|
||||
let bs = wordcraft_doc::props::Borders { top: Some(*b), ..Default::default() };
|
||||
props::borders(w, "w:tcBorders", &bs, None, &[]);
|
||||
}
|
||||
if let Some(f) = fill {
|
||||
w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &f.hex())]);
|
||||
}
|
||||
w.close("w:tcPr");
|
||||
}
|
||||
w.close("w:tblStylePr");
|
||||
};
|
||||
w.close("w:tcPr");
|
||||
}
|
||||
w.close("w:tblStylePr");
|
||||
};
|
||||
cond(w, "wholeTable", &wordcraft_doc::CharProps::default(), t.fill, None);
|
||||
cond(w, "firstRow", &t.header_chr, t.header_fill, None);
|
||||
cond(w, "lastRow", &t.total_chr, None, t.total_border_top.as_ref());
|
||||
cond(w, "firstCol", &t.first_col_chr, None, None);
|
||||
cond(w, "band1Horz", &wordcraft_doc::CharProps::default(), t.band_fill, None);
|
||||
cond(w, "firstRow", &t.header_chr, t.header_fill, t.header_borders);
|
||||
// The total row's borders, its top edge falling back to the built-in top rule.
|
||||
let total_borders = match (t.total_borders, t.total_border_top) {
|
||||
(Some(mut b), top) => {
|
||||
b.top = b.top.or(top);
|
||||
Some(b)
|
||||
}
|
||||
(None, top) => top.map(|b| wordcraft_doc::props::Borders { top: Some(b), ..Default::default() }),
|
||||
};
|
||||
cond(w, "lastRow", &t.total_chr, t.total_fill, total_borders);
|
||||
cond(w, "firstCol", &t.first_col_chr, t.first_col_fill, t.first_col_borders);
|
||||
cond(w, "lastCol", &t.last_col_chr, t.last_col_fill, t.last_col_borders);
|
||||
cond(w, "band1Vert", &t.col_band_chr, t.col_band_fill, t.col_band_borders);
|
||||
cond(w, "band1Horz", &t.band_chr, t.band_fill, t.band_borders);
|
||||
}
|
||||
w.close("w:style");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -230,6 +230,12 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool) {
|
|||
w.close("w:tabs");
|
||||
}
|
||||
toggle(w, "w:suppressAutoHyphens", p.suppress_hyphens);
|
||||
toggle(w, "w:kinsoku", p.kinsoku);
|
||||
toggle(w, "w:wordWrap", p.word_wrap);
|
||||
toggle(w, "w:overflowPunct", p.overflow_punct);
|
||||
toggle(w, "w:topLinePunct", p.top_line_punct);
|
||||
toggle(w, "w:autoSpaceDE", p.auto_space_de);
|
||||
toggle(w, "w:autoSpaceDN", p.auto_space_dn);
|
||||
toggle(w, "w:bidi", p.bidi);
|
||||
if p.space_before.is_some() || p.space_after.is_some() || p.line_spacing.is_some() {
|
||||
let before = p.space_before.map(|v| twips(v.max(0.0)));
|
||||
|
|
|
|||
|
|
@ -370,10 +370,8 @@ impl Writer<'_> {
|
|||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
InlineObject::Image { media, w: iw, h: ih, alt, float, crop } => {
|
||||
InlineObject::Image { media, w: iw, h: ih, alt, float, .. } => {
|
||||
let Some(file) = self.media_files.get(media).cloned() else { return };
|
||||
let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false);
|
||||
self.used_media.insert(media.clone());
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
rpr(w, props);
|
||||
|
|
@ -384,30 +382,7 @@ impl Writer<'_> {
|
|||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.open("a:graphic", &[]);
|
||||
w.open("a:graphicData", &[("uri", "http://schemas.openxmlformats.org/drawingml/2006/picture")]);
|
||||
w.open("pic:pic", &[]);
|
||||
w.open("pic:nvPicPr", &[]);
|
||||
w.empty("pic:cNvPr", &[("id", "0"), ("name", &file), ("descr", alt)]);
|
||||
w.open("pic:cNvPicPr", &[]);
|
||||
w.empty("a:picLocks", &[("noChangeAspect", "1"), ("noChangeArrowheads", "1")]);
|
||||
w.close("pic:cNvPicPr");
|
||||
w.close("pic:nvPicPr");
|
||||
w.open("pic:blipFill", &[]);
|
||||
w.empty("a:blip", &[("r:embed", &rid)]);
|
||||
if crop.iter().any(|c| *c > 0.0) {
|
||||
let c = |v: f32| n((wordcraft_geom::finite(v).clamp(0.0, 1.0) * 100_000.0).round() as i64);
|
||||
w.empty("a:srcRect", &[("l", &c(crop[0])), ("t", &c(crop[1])), ("r", &c(crop[2])), ("b", &c(crop[3]))]);
|
||||
}
|
||||
w.open("a:stretch", &[]);
|
||||
w.empty("a:fillRect", &[]);
|
||||
w.close("a:stretch");
|
||||
w.close("pic:blipFill");
|
||||
w.open("pic:spPr", &[("bwMode", "auto")]);
|
||||
xfrm(w, *iw, *ih);
|
||||
w.open("a:prstGeom", &[("prst", "rect")]);
|
||||
w.empty("a:avLst", &[]);
|
||||
w.close("a:prstGeom");
|
||||
w.close("pic:spPr");
|
||||
w.close("pic:pic");
|
||||
self.pic(w, o, &file, (0.0, 0.0), rels);
|
||||
w.close("a:graphicData");
|
||||
w.close("a:graphic");
|
||||
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
|
||||
|
|
@ -415,7 +390,7 @@ impl Writer<'_> {
|
|||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
InlineObject::Shape { kind, w: sw, h: sh, fill, stroke, stroke_width, float, story, effects } => {
|
||||
InlineObject::Shape { w: sw, h: sh, float, effects, .. } => {
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
rpr(w, props);
|
||||
|
|
@ -432,67 +407,53 @@ impl Writer<'_> {
|
|||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.open("a:graphic", &[]);
|
||||
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape")]);
|
||||
w.open("wps:wsp", &[]);
|
||||
if *kind == ShapeKind::TextBox {
|
||||
w.empty("wps:cNvSpPr", &[("txBox", "1")]);
|
||||
} else {
|
||||
w.empty("wps:cNvSpPr", &[]);
|
||||
}
|
||||
w.open("wps:spPr", &[]);
|
||||
xfrm(w, *sw, *sh);
|
||||
let prst = match kind {
|
||||
ShapeKind::Rectangle | ShapeKind::TextBox => "rect",
|
||||
ShapeKind::RoundedRectangle => "roundRect",
|
||||
ShapeKind::Ellipse => "ellipse",
|
||||
ShapeKind::Triangle => "triangle",
|
||||
ShapeKind::Diamond => "diamond",
|
||||
ShapeKind::Line => "line",
|
||||
ShapeKind::Arrow => "rightArrow",
|
||||
ShapeKind::Star => "star5",
|
||||
ShapeKind::Heart => "heart",
|
||||
};
|
||||
w.open("a:prstGeom", &[("prst", prst)]);
|
||||
w.empty("a:avLst", &[]);
|
||||
w.close("a:prstGeom");
|
||||
match fill {
|
||||
Some(c) => {
|
||||
w.open("a:solidFill", &[]);
|
||||
w.empty("a:srgbClr", &[("val", &c.hex())]);
|
||||
w.close("a:solidFill");
|
||||
}
|
||||
None => w.empty("a:noFill", &[]),
|
||||
}
|
||||
match stroke {
|
||||
Some(c) => {
|
||||
w.open("a:ln", &[("w", &emu(stroke_width.clamp(0.0, 100.0)))]);
|
||||
w.open("a:solidFill", &[]);
|
||||
w.empty("a:srgbClr", &[("val", &c.hex())]);
|
||||
w.close("a:solidFill");
|
||||
w.close("a:ln");
|
||||
}
|
||||
None => {
|
||||
w.open("a:ln", &[]);
|
||||
w.empty("a:noFill", &[]);
|
||||
w.close("a:ln");
|
||||
self.wsp(w, o, None, (0.0, 0.0), rels, depth);
|
||||
w.close("a:graphicData");
|
||||
w.close("a:graphic");
|
||||
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
|
||||
w.close("w:drawing");
|
||||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
InlineObject::Group { w: gw, h: gh, float, ch_w, ch_h, children } => {
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.next_docpr();
|
||||
let name = format!("Group {docpr}");
|
||||
self.drawing_open(w, float, *gw, *gh, &docpr, &name, "");
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.open("a:graphic", &[]);
|
||||
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup")]);
|
||||
// ECMA-376 §20.1.7.6 (`a:xfrm` of a group): the members' space `a:chOff`/`a:chExt`
|
||||
// maps onto the group's `a:off`/`a:ext`.
|
||||
w.open("wpg:wgp", &[]);
|
||||
w.empty("wpg:cNvGrpSpPr", &[]);
|
||||
w.open("wpg:grpSpPr", &[]);
|
||||
w.open("a:xfrm", &[]);
|
||||
w.empty("a:off", &[("x", "0"), ("y", "0")]);
|
||||
w.empty("a:ext", &[("cx", &emu(gw.max(0.0))), ("cy", &emu(gh.max(0.0)))]);
|
||||
w.empty("a:chOff", &[("x", "0"), ("y", "0")]);
|
||||
w.empty("a:chExt", &[("cx", &emu(ch_w.max(0.0))), ("cy", &emu(ch_h.max(0.0)))]);
|
||||
w.close("a:xfrm");
|
||||
w.close("wpg:grpSpPr");
|
||||
for c in children.iter().take(wordcraft_doc::para::MAX_GROUP_CHILDREN) {
|
||||
let at = (c.x, c.y);
|
||||
match &c.obj {
|
||||
InlineObject::Image { media, .. } => {
|
||||
if let Some(file) = self.media_files.get(media).cloned() {
|
||||
self.pic(w, &c.obj, &file, at, rels);
|
||||
}
|
||||
}
|
||||
InlineObject::Shape { .. } => {
|
||||
let id = self.next_docpr();
|
||||
self.wsp(w, &c.obj, Some(&id), at, rels, depth);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
effect_list(w, effects);
|
||||
w.close("wps:spPr");
|
||||
// Its text, within the same bounds layout shows boxes inside boxes with.
|
||||
if let Some(id) = *story
|
||||
&& let Some(part) = self.doc.parts.get(&id).filter(|p| p.kind == wordcraft_doc::PartKind::TextBox)
|
||||
&& self.boxes.enter(id)
|
||||
{
|
||||
w.open("wps:txbx", &[]);
|
||||
w.open("w:txbxContent", &[]);
|
||||
let blocks = part.blocks.clone();
|
||||
self.blocks(w, &blocks, rels, false, depth + 1);
|
||||
w.close("w:txbxContent");
|
||||
w.close("wps:txbx");
|
||||
self.boxes.leave();
|
||||
}
|
||||
w.empty("wps:bodyPr", &[]);
|
||||
w.close("wps:wsp");
|
||||
w.close("wpg:wgp");
|
||||
w.close("a:graphicData");
|
||||
w.close("a:graphic");
|
||||
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
|
||||
|
|
@ -582,6 +543,108 @@ impl Writer<'_> {
|
|||
run(w, &|w| w.empty("w:fldChar", &[("w:fldCharType", "separate")]));
|
||||
}
|
||||
|
||||
/// A picture's `pic:pic` (the image in `media/{file}`), at `off` in its group (or 0, 0).
|
||||
fn pic(&mut self, w: &mut W, o: &InlineObject, file: &str, off: (f32, f32), rels: &mut PartRels) {
|
||||
let InlineObject::Image { media, w: iw, h: ih, alt, crop, .. } = o else { return };
|
||||
let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false);
|
||||
self.used_media.insert(media.clone());
|
||||
w.open("pic:pic", &[]);
|
||||
w.open("pic:nvPicPr", &[]);
|
||||
w.empty("pic:cNvPr", &[("id", "0"), ("name", file), ("descr", alt)]);
|
||||
w.open("pic:cNvPicPr", &[]);
|
||||
w.empty("a:picLocks", &[("noChangeAspect", "1"), ("noChangeArrowheads", "1")]);
|
||||
w.close("pic:cNvPicPr");
|
||||
w.close("pic:nvPicPr");
|
||||
w.open("pic:blipFill", &[]);
|
||||
w.empty("a:blip", &[("r:embed", &rid)]);
|
||||
if crop.iter().any(|c| *c > 0.0) {
|
||||
let c = |v: f32| n((wordcraft_geom::finite(v).clamp(0.0, 1.0) * 100_000.0).round() as i64);
|
||||
w.empty("a:srcRect", &[("l", &c(crop[0])), ("t", &c(crop[1])), ("r", &c(crop[2])), ("b", &c(crop[3]))]);
|
||||
}
|
||||
w.open("a:stretch", &[]);
|
||||
w.empty("a:fillRect", &[]);
|
||||
w.close("a:stretch");
|
||||
w.close("pic:blipFill");
|
||||
w.open("pic:spPr", &[("bwMode", "auto")]);
|
||||
xfrm(w, off, *iw, *ih);
|
||||
w.open("a:prstGeom", &[("prst", "rect")]);
|
||||
w.empty("a:avLst", &[]);
|
||||
w.close("a:prstGeom");
|
||||
w.close("pic:spPr");
|
||||
w.close("pic:pic");
|
||||
}
|
||||
|
||||
/// A shape's or text box's `wps:wsp`, at `off` in its group (or 0, 0). In a group it carries
|
||||
/// its own `wps:cNvPr` with drawing id `id`.
|
||||
fn wsp(&mut self, w: &mut W, o: &InlineObject, id: Option<&str>, off: (f32, f32), rels: &mut PartRels, depth: usize) {
|
||||
let InlineObject::Shape { kind, w: sw, h: sh, fill, stroke, stroke_width, story, effects, .. } = o else { return };
|
||||
w.open("wps:wsp", &[]);
|
||||
if let Some(id) = id {
|
||||
let name = format!("Shape {id}");
|
||||
w.empty("wps:cNvPr", &[("id", id), ("name", &name)]);
|
||||
}
|
||||
if *kind == ShapeKind::TextBox {
|
||||
w.empty("wps:cNvSpPr", &[("txBox", "1")]);
|
||||
} else {
|
||||
w.empty("wps:cNvSpPr", &[]);
|
||||
}
|
||||
w.open("wps:spPr", &[]);
|
||||
xfrm(w, off, *sw, *sh);
|
||||
let prst = match kind {
|
||||
ShapeKind::Rectangle | ShapeKind::TextBox => "rect",
|
||||
ShapeKind::RoundedRectangle => "roundRect",
|
||||
ShapeKind::Ellipse => "ellipse",
|
||||
ShapeKind::Triangle => "triangle",
|
||||
ShapeKind::Diamond => "diamond",
|
||||
ShapeKind::Line => "line",
|
||||
ShapeKind::Arrow => "rightArrow",
|
||||
ShapeKind::Star => "star5",
|
||||
ShapeKind::Heart => "heart",
|
||||
};
|
||||
w.open("a:prstGeom", &[("prst", prst)]);
|
||||
w.empty("a:avLst", &[]);
|
||||
w.close("a:prstGeom");
|
||||
match fill {
|
||||
Some(c) => {
|
||||
w.open("a:solidFill", &[]);
|
||||
w.empty("a:srgbClr", &[("val", &c.hex())]);
|
||||
w.close("a:solidFill");
|
||||
}
|
||||
None => w.empty("a:noFill", &[]),
|
||||
}
|
||||
match stroke {
|
||||
Some(c) => {
|
||||
w.open("a:ln", &[("w", &emu(stroke_width.clamp(0.0, 100.0)))]);
|
||||
w.open("a:solidFill", &[]);
|
||||
w.empty("a:srgbClr", &[("val", &c.hex())]);
|
||||
w.close("a:solidFill");
|
||||
w.close("a:ln");
|
||||
}
|
||||
None => {
|
||||
w.open("a:ln", &[]);
|
||||
w.empty("a:noFill", &[]);
|
||||
w.close("a:ln");
|
||||
}
|
||||
}
|
||||
effect_list(w, effects);
|
||||
w.close("wps:spPr");
|
||||
// Its text, within the same bounds layout shows boxes inside boxes with.
|
||||
if let Some(id) = *story
|
||||
&& let Some(part) = self.doc.parts.get(&id).filter(|p| p.kind == wordcraft_doc::PartKind::TextBox)
|
||||
&& self.boxes.enter(id)
|
||||
{
|
||||
w.open("wps:txbx", &[]);
|
||||
w.open("w:txbxContent", &[]);
|
||||
let blocks = part.blocks.clone();
|
||||
self.blocks(w, &blocks, rels, false, depth + 1);
|
||||
w.close("w:txbxContent");
|
||||
w.close("wps:txbx");
|
||||
self.boxes.leave();
|
||||
}
|
||||
w.empty("wps:bodyPr", &[]);
|
||||
w.close("wps:wsp");
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn drawing_open(&mut self, w: &mut W, float: &Float, iw: f32, ih: f32, docpr: &str, name: &str, alt: &str) {
|
||||
let (cx, cy) = (emu(iw.max(0.0)), emu(ih.max(0.0)));
|
||||
|
|
@ -826,9 +889,9 @@ fn effect_list(w: &mut W, effects: &ShapeEffects) {
|
|||
w.close("a:effectLst");
|
||||
}
|
||||
|
||||
fn xfrm(w: &mut W, cw: f32, ch: f32) {
|
||||
fn xfrm(w: &mut W, (x, y): (f32, f32), cw: f32, ch: f32) {
|
||||
w.open("a:xfrm", &[]);
|
||||
w.empty("a:off", &[("x", "0"), ("y", "0")]);
|
||||
w.empty("a:off", &[("x", &emu(x)), ("y", &emu(y))]);
|
||||
w.empty("a:ext", &[("cx", &emu(cw.max(0.0))), ("cy", &emu(ch.max(0.0)))]);
|
||||
w.close("a:xfrm");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use wordcraft_doc::para::{Anchor, FloatAlign, NoteKind, ShapeKind, Wrap};
|
|||
use wordcraft_doc::props::{Align, Border, BorderStyle, Rgb, TextColor, VMerge};
|
||||
use wordcraft_doc::{Block, Document, InlineObject, Paragraph};
|
||||
|
||||
const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office""#;
|
||||
const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office""#;
|
||||
|
||||
const ROOT_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
|
||||
|
|
@ -646,3 +646,28 @@ fn hostile_vml_style_values_stay_finite() {
|
|||
assert!([*w, *h, float.x, float.y, float.dist, float.dist_top].iter().all(|v| v.is_finite()), "{w} {h} {float:?}");
|
||||
assert_eq!((float.h_align, float.dist, float.dist_top), (None, 0.0, 1584.0));
|
||||
}
|
||||
|
||||
/// A group as Word saves one (in `mc:AlternateContent`, needing `wpg`): its members' space
|
||||
/// starts at `a:chOff`, and a nested group is flattened into it through its own transform.
|
||||
#[test]
|
||||
fn word_group_with_nested_group() {
|
||||
let body = r#"<w:p><w:r><mc:AlternateContent><mc:Choice Requires="wpg"><w:drawing><wp:anchor behindDoc="0" distT="0" distB="0" distL="114300" distR="114300" simplePos="0" relativeHeight="1" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="column"><wp:posOffset>0</wp:posOffset></wp:positionH><wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV><wp:extent cx="2540000" cy="1270000"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapSquare wrapText="bothSides"/><wp:docPr id="1" name="Group 1"/><wp:cNvGraphicFramePr/>
|
||||
<a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"><wpg:wgp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2540000" cy="1270000"/><a:chOff x="127000" y="127000"/><a:chExt cx="2540000" cy="1270000"/></a:xfrm></wpg:grpSpPr>
|
||||
<wps:wsp><wps:cNvPr id="2" name="Rectangle 2"/><wps:cNvSpPr/><wps:spPr><a:xfrm><a:off x="127000" y="127000"/><a:ext cx="1270000" cy="635000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></wps:spPr><wps:bodyPr/></wps:wsp>
|
||||
<wpg:grpSp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="1397000" y="762000"/><a:ext cx="1270000" cy="635000"/><a:chOff x="0" y="0"/><a:chExt cx="2540000" cy="1270000"/></a:xfrm></wpg:grpSpPr>
|
||||
<wps:wsp><wps:cNvPr id="3" name="Oval 3"/><wps:cNvSpPr/><wps:spPr><a:xfrm><a:off x="254000" y="254000"/><a:ext cx="508000" cy="254000"/></a:xfrm><a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom></wps:spPr><wps:bodyPr/></wps:wsp>
|
||||
</wpg:grpSp></wpg:wgp></a:graphicData></a:graphic></wp:anchor></w:drawing></mc:Choice><mc:Fallback><w:pict/></mc:Fallback></mc:AlternateContent></w:r></w:p>"#;
|
||||
let d = read_body(body);
|
||||
let p = paras(&d);
|
||||
let InlineObject::Group { w, h, float, ch_w, ch_h, children } = &p[0].objects[0] else { panic!("{:?}", p[0].objects) };
|
||||
assert_eq!((*w, *h, *ch_w, *ch_h), (200.0, 100.0, 200.0, 100.0));
|
||||
assert_eq!(float.wrap, Wrap::Square);
|
||||
let got: Vec<(f32, f32, ShapeKind, f32, f32)> = children
|
||||
.iter()
|
||||
.map(|c| match &c.obj {
|
||||
InlineObject::Shape { kind, w, h, .. } => (c.x, c.y, *kind, *w, *h),
|
||||
o => panic!("{o:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(got, [(0.0, 0.0, ShapeKind::Rectangle, 100.0, 50.0), (110.0, 60.0, ShapeKind::Ellipse, 20.0, 10.0)]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,11 +136,18 @@ fn every_para_prop_round_trips() {
|
|||
suppress_line_numbers: Some(true),
|
||||
bidi: Some(false),
|
||||
drop_cap: None,
|
||||
kinsoku: Some(false),
|
||||
word_wrap: Some(false),
|
||||
overflow_punct: Some(false),
|
||||
top_line_punct: Some(true),
|
||||
auto_space_de: Some(false),
|
||||
auto_space_dn: Some(true),
|
||||
};
|
||||
let variants = [
|
||||
ParaProps { line_spacing: Some(LineSpacing::AtLeast(14.0)), indent_first: Some(24.0), align: Some(Align::Center), ..Default::default() },
|
||||
ParaProps { line_spacing: Some(LineSpacing::Exactly(20.0)), align: Some(Align::Right), ..Default::default() },
|
||||
ParaProps { align: Some(Align::Distribute), ..Default::default() },
|
||||
ParaProps { kinsoku: Some(true), word_wrap: Some(true), auto_space_dn: Some(false), ..Default::default() },
|
||||
ParaProps::default(),
|
||||
];
|
||||
let mut ps = vec![Paragraph::with_text("main", CharProps::default())];
|
||||
|
|
@ -429,7 +436,8 @@ fn comments_round_trip() {
|
|||
}
|
||||
}
|
||||
|
||||
/// A table style's cell text formatting, whole-table shading and cell margins survive a save.
|
||||
/// A table style's cell text formatting, whole-table shading, cell margins and its header row and
|
||||
/// row band conditional formats (#146) survive a save.
|
||||
#[test]
|
||||
fn table_style_formatting_round_trips() {
|
||||
use wordcraft_doc::styles::TableStyleParts;
|
||||
|
|
@ -440,6 +448,11 @@ fn table_style_formatting_round_trips() {
|
|||
cell_margins: Some([1.0, 14.4, 0.0, 14.4]),
|
||||
header_chr: CharProps { italic: Some(true), ..Default::default() },
|
||||
header_fill: Some(Rgb(0xFF, 0xFF, 0)),
|
||||
header_borders: Some(Borders::all(Border::single(1.5))),
|
||||
band_fill: Some(Rgb(0xF2, 0xF2, 0xF2)),
|
||||
band_chr: CharProps { bold: Some(true), color: Some(TextColor::Rgb(Rgb(0, 0x70, 0xC0))), ..Default::default() },
|
||||
band_borders: Some(Borders::box_(Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 })),
|
||||
band_size: Some(3),
|
||||
..Default::default()
|
||||
};
|
||||
d.styles.upsert(Style {
|
||||
|
|
@ -843,6 +856,82 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
|
|||
assert_eq!(got[1].props, dc.props);
|
||||
}
|
||||
|
||||
/// A group of a picture, a shape and a text box (`wpg:wgp`) comes back as it was saved: its
|
||||
/// size, placement and members' space, and each member's offset, size and content.
|
||||
#[test]
|
||||
fn groups_round_trip() {
|
||||
use wordcraft_doc::para::GroupChild;
|
||||
let mut d = Document::new();
|
||||
let key = d.add_media(tiny_png(), "png");
|
||||
let story = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("In the group", CharProps::default()))]);
|
||||
let pic = InlineObject::Image { media: key, w: 60.0, h: 40.0, alt: "A picture".into(), float: Float::default(), crop: [0.0; 4] };
|
||||
let oval = InlineObject::Shape {
|
||||
kind: ShapeKind::Ellipse,
|
||||
w: 50.0,
|
||||
h: 30.0,
|
||||
fill: Some(Rgb(200, 0, 0)),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Float::default(),
|
||||
story: None,
|
||||
// A member's shape effects (#275) come back too.
|
||||
effects: wordcraft_doc::effects::ShapeEffects {
|
||||
shadow: Some(wordcraft_doc::effects::Shadow { color: Rgb(0x20, 0x30, 0x40), transparency: 60.0, blur: 4.0, distance: 3.0, angle: 135.0 }),
|
||||
glow: None,
|
||||
soft_edge: Some(2.5),
|
||||
},
|
||||
};
|
||||
let tb = InlineObject::Shape {
|
||||
kind: ShapeKind::TextBox,
|
||||
w: 100.0,
|
||||
h: 40.0,
|
||||
fill: Some(Rgb(255, 255, 255)),
|
||||
stroke: Some(Rgb(0, 0, 0)),
|
||||
stroke_width: 0.75,
|
||||
float: Float::default(),
|
||||
story: Some(story),
|
||||
effects: Default::default(),
|
||||
};
|
||||
let float = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 20.0, y: 10.0, dist: 9.0, ..Default::default() };
|
||||
let group = InlineObject::Group {
|
||||
w: 240.0,
|
||||
h: 120.0,
|
||||
float,
|
||||
ch_w: 200.0,
|
||||
ch_h: 100.0,
|
||||
children: vec![
|
||||
GroupChild { x: 0.0, y: 0.0, obj: pic },
|
||||
GroupChild { x: 150.0, y: 10.0, obj: oval.clone() },
|
||||
GroupChild { x: 50.0, y: 60.0, obj: tb },
|
||||
],
|
||||
};
|
||||
let mut p = Paragraph::with_text("grouped ", CharProps::default());
|
||||
let end = p.len();
|
||||
p.insert_object(end, group, &CharProps::default()).unwrap();
|
||||
d.body = vec![para_block(p)];
|
||||
let r = rt(&d);
|
||||
let got = paras(&r);
|
||||
let InlineObject::Group { w, h, float: f, ch_w, ch_h, children } = &got[0].objects[0] else { panic!("{:?}", got[0].objects) };
|
||||
assert_eq!((*w, *h, *ch_w, *ch_h), (240.0, 120.0, 200.0, 100.0));
|
||||
assert_eq!(*f, float);
|
||||
assert_eq!(children.iter().map(|c| (c.x, c.y)).collect::<Vec<_>>(), [(0.0, 0.0), (150.0, 10.0), (50.0, 60.0)]);
|
||||
match &children[0].obj {
|
||||
InlineObject::Image { media, w, h, alt, .. } => {
|
||||
assert_eq!((*w, *h, alt.as_str()), (60.0, 40.0, "A picture"));
|
||||
assert_eq!(r.media.get(media).map(|m| m.as_slice()), Some(tiny_png().as_slice()));
|
||||
}
|
||||
o => panic!("{o:?}"),
|
||||
}
|
||||
assert_eq!(children[1].obj, oval);
|
||||
match &children[2].obj {
|
||||
InlineObject::Shape { kind, w, h, story, .. } => {
|
||||
assert_eq!((*kind, *w, *h), (ShapeKind::TextBox, 100.0, 40.0));
|
||||
assert_eq!(part_text(&r, *story), "In the group");
|
||||
}
|
||||
o => panic!("{o:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_mode_round_trips() {
|
||||
// New documents are Word 2013+ documents; an older file keeps its mode, so saving it doesn't
|
||||
|
|
|
|||
|
|
@ -327,13 +327,16 @@ View|Macros|Record Macro|tools.recordMacro
|
|||
View|SharePoint|Properties|file.properties
|
||||
Table Design|Table Style Options|Table Style Options|table.look
|
||||
Table Design|Table Styles|Table Styles|table.style
|
||||
Table Design|Table Styles|New Table Style|table.newStyle
|
||||
Table Design|Table Styles|Modify Table Style|table.modifyStyle
|
||||
Table Design|Table Styles|Delete Table Style|table.deleteStyle
|
||||
Table Design|Table Styles|Shading|table.shading
|
||||
Table Design|Borders|Borders|table.borders
|
||||
Table Design|Borders|Border Painter|table.borderPainter
|
||||
Table Layout|Table|Select Table|table.selectTable
|
||||
Table Layout|Table|Select Row|table.selectRow
|
||||
Table Layout|Table|Select Cell|table.selectCell
|
||||
Table Layout|Table|View Gridlines|view.gridlines
|
||||
Table Layout|Table|View Gridlines|table.viewGridlines
|
||||
Table Layout|Table|Properties|table.properties
|
||||
Table Layout|Draw|Draw Table|table.draw
|
||||
Table Layout|Draw|Eraser|table.eraser
|
||||
|
|
@ -500,6 +503,6 @@ mod tests {
|
|||
let p = parity(®);
|
||||
let pct = p["percent"].as_f64().unwrap();
|
||||
// The floor only ever rises.
|
||||
assert!(pct >= 60.0, "parity {pct}%");
|
||||
assert!(pct >= 90.4, "parity {pct}%");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,232 @@
|
|||
//! Column (block) selection: Alt+drag, or Ctrl+Shift+F8 then the arrow keys, selects the same
|
||||
//! horizontal range on a run of lines. Copy and Cut take one line per block row, Delete /
|
||||
//! Backspace remove just the block, character formatting applies to each row's piece, paragraph
|
||||
//! formatting to the paragraphs it touches. Typing or pasting first deletes the block; any other
|
||||
//! edit collapses it to its top-left corner. Any ordinary caret move or selection drops it.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::edit::Fragment;
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Block, Paragraph, Pos};
|
||||
|
||||
use super::{parse_pos, pos_json};
|
||||
use crate::{CmdError, CmdResult, ColumnBlock, CommandSpec, Selection, Session, p};
|
||||
|
||||
/// Most lines a block covers (hostile params: a block over a huge document).
|
||||
const MAX_LINES: usize = 100_000;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("select.column", "Column Selection", "Editing › Selection", select_column)
|
||||
.key("Mod+Shift+F8")
|
||||
.params(
|
||||
r#"{"anchor": Pos, "focus": Pos} | {"from": {"page": n, "x": pt, "y": pt}, "to": {"page": n, "x": pt, "y": pt}, "story"?: StoryRef} | {"on"?: bool} (no corners: toggle column selection mode, where the arrow keys extend the block)"#,
|
||||
)
|
||||
.pure(),
|
||||
]
|
||||
}
|
||||
|
||||
/// Result: the corners, each block row's text and whether column mode is on.
|
||||
fn result(s: &Session) -> CmdResult {
|
||||
let rows: Vec<String> = s.column_segments().map(|segs| segs.iter().map(|(a, b)| row_text(s, a, b)).collect()).unwrap_or_default();
|
||||
Ok(json!({"anchor": pos_json(&s.sel.anchor), "focus": pos_json(&s.sel.focus), "rows": rows, "columnMode": s.column_mode}))
|
||||
}
|
||||
|
||||
fn row_text(s: &Session, a: &Pos, b: &Pos) -> String {
|
||||
let (a, b) = (s.doc.clamp(a), s.doc.clamp(b));
|
||||
if a.path != b.path || a.story != b.story {
|
||||
return String::new();
|
||||
}
|
||||
s.doc.para_at(&a).and_then(|p| p.text.get(a.off.min(b.off)..b.off.max(a.off))).map(str::to_string).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `{"page": n, "x": pt, "y": pt}`.
|
||||
fn point(v: &Value, name: &str) -> Result<(usize, f32, f32), CmdError> {
|
||||
match (p::u64(v, "page"), p::f32(v, "x"), p::f32(v, "y")) {
|
||||
(Some(pg), Some(x), Some(y)) => Ok((usize::try_from(pg).unwrap_or(usize::MAX), x, y)),
|
||||
_ => Err(CmdError::Params(format!("`{name}` needs `page`, `x` and `y`"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_column(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let (Some(av), Some(fv)) = (v.get("anchor"), v.get("focus")) {
|
||||
let a = parse_pos(av).ok_or_else(|| CmdError::Params("bad `anchor`".into()))?;
|
||||
let b = parse_pos(fv).ok_or_else(|| CmdError::Params("bad `focus`".into()))?;
|
||||
let (a, b) = (s.doc.clamp(&a), s.doc.clamp(&b));
|
||||
if a.story != b.story {
|
||||
return Err(CmdError::Params("`anchor` and `focus` must be in the same story".into()));
|
||||
}
|
||||
let l = s.layout();
|
||||
let xa = l.caret_on(&a, s.page_hint).map(|c| c.x).ok_or_else(|| CmdError::Failed("`anchor` isn't laid out".into()))?;
|
||||
let xb = l.caret_on(&b, s.page_hint).map(|c| c.x).ok_or_else(|| CmdError::Failed("`focus` isn't laid out".into()))?;
|
||||
make(s, a, b, xa, xb);
|
||||
return result(s);
|
||||
}
|
||||
if let (Some(fv), Some(tv)) = (v.get("from"), v.get("to")) {
|
||||
let (fp, fx, fy) = point(fv, "from")?;
|
||||
let (tp, tx, ty) = point(tv, "to")?;
|
||||
let story = super::story_param(s, v);
|
||||
let l = s.layout();
|
||||
let a = l.hit(fp, fx, fy, story).ok_or_else(|| CmdError::Params("no text at `from`".into()))?;
|
||||
let b = l.hit(tp, tx, ty, story).ok_or_else(|| CmdError::Params("no text at `to`".into()))?;
|
||||
s.page_hint = tp;
|
||||
make(s, a, b, fx, tx);
|
||||
return result(s);
|
||||
}
|
||||
// Ctrl+Shift+F8: toggle the mode; the block starts at the caret.
|
||||
let on = p::bool(v, "on").unwrap_or(!s.column_mode);
|
||||
s.column_mode = on;
|
||||
if on {
|
||||
let f = s.sel.focus.clone();
|
||||
let x = s.layout().caret_on(&f, s.page_hint).map(|c| c.x).unwrap_or(0.0);
|
||||
make(s, f.clone(), f, x, x);
|
||||
s.status = "Column selection: the arrow keys extend the block, Esc ends it".into();
|
||||
} else {
|
||||
s.status.clear();
|
||||
}
|
||||
result(s)
|
||||
}
|
||||
|
||||
/// Make the block between corners `a` and `b` whose page x are `xa` and `xb`.
|
||||
fn make(s: &mut Session, a: Pos, b: Pos, xa: f32, xb: f32) {
|
||||
let (xa, xb) = (if xa.is_finite() { xa } else { 0.0 }, if xb.is_finite() { xb } else { 0.0 });
|
||||
let (left, right) = (xa.min(xb), xa.max(xb));
|
||||
let l = s.layout();
|
||||
let segments = l.column_segments(&s.doc, &a, &b, left, right, s.page_hint, MAX_LINES);
|
||||
s.sel = Selection { anchor: a, focus: b };
|
||||
s.column = Some(ColumnBlock { sel: s.sel.clone(), anchor_x: xa, focus_x: xb, segments });
|
||||
s.pending = None;
|
||||
}
|
||||
|
||||
/// Column mode: the caret moved to `to`; grow the block to it (keeping the goal column on
|
||||
/// Up/Down).
|
||||
fn extend_to(s: &mut Session, to: Pos) {
|
||||
let Some(block) = s.column.clone() else { return };
|
||||
let x = match s.goal_x {
|
||||
Some(gx) => gx,
|
||||
None => s.layout().caret_on(&to, s.page_hint).map(|c| c.x).unwrap_or(block.focus_x),
|
||||
};
|
||||
make(s, block.sel.anchor, to, block.anchor_x, x);
|
||||
}
|
||||
|
||||
/// Run a command with the column block in mind (called by `Session::run` for every command).
|
||||
pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Session, &Value) -> CmdResult, v: &Value) -> CmdResult {
|
||||
// A block the selection has moved away from is gone.
|
||||
if s.column.as_ref().is_some_and(|c| c.sel != s.sel) {
|
||||
s.column = None;
|
||||
s.column_mode = false;
|
||||
}
|
||||
if s.column_mode && id.starts_with("caret.") && id != "caret.set" {
|
||||
let mut params = v.clone();
|
||||
match params.as_object_mut() {
|
||||
Some(o) => {
|
||||
o.insert("extend".into(), Value::Bool(true));
|
||||
}
|
||||
None => params = json!({"extend": true}),
|
||||
}
|
||||
run(s, ¶ms)?;
|
||||
let to = s.sel.focus.clone();
|
||||
extend_to(s, to);
|
||||
return result(s);
|
||||
}
|
||||
if matches!(id, "caret.set" | "select.collapse") || (mutates && !id.starts_with("format.") && !id.starts_with("para.")) {
|
||||
s.column_mode = false;
|
||||
}
|
||||
let Some(segs) = s.column_segments().map(<[(Pos, Pos)]>::to_vec) else { return run(s, v) };
|
||||
match id {
|
||||
"edit.copy" => copy(s, &segs),
|
||||
"edit.cut" => {
|
||||
let r = copy(s, &segs)?;
|
||||
delete(s, &segs)?;
|
||||
Ok(r)
|
||||
}
|
||||
"text.delete" | "text.backspace" | "text.deleteWordBack" | "text.deleteWordForward" => {
|
||||
delete(s, &segs)?;
|
||||
super::sel_result(s)
|
||||
}
|
||||
"format.changeCase" | "format.clear" => per_row(s, run, v, &segs),
|
||||
_ if id.starts_with("format.") || id.starts_with("para.") || id == "select.column" => run(s, v),
|
||||
_ if mutates && (id.starts_with("text.") || id.starts_with("edit.paste")) => {
|
||||
delete(s, &segs)?;
|
||||
run(s, v)
|
||||
}
|
||||
_ if mutates => {
|
||||
let at = segs.first().map(|(a, _)| s.doc.clamp(a)).unwrap_or_else(|| s.sel.focus.clone());
|
||||
s.sel = Selection::caret(at);
|
||||
s.column = None;
|
||||
run(s, v)
|
||||
}
|
||||
_ => run(s, v),
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy: one paragraph per block row (formatting kept); the plain text joins rows with newlines.
|
||||
fn copy(s: &mut Session, segs: &[(Pos, Pos)]) -> CmdResult {
|
||||
let mut blocks = Vec::new();
|
||||
let mut parts = std::collections::BTreeMap::new();
|
||||
let mut rows = Vec::new();
|
||||
for (a, b) in segs {
|
||||
let (a, b) = (s.doc.clamp(a), s.doc.clamp(b));
|
||||
let frag = if a.path == b.path && a.story == b.story && a != b { s.doc.copy_range(&a, &b) } else { Fragment::default() };
|
||||
// Text boxes in a row keep their stories (keyed by this document's part ids).
|
||||
parts.extend(frag.parts);
|
||||
let mut para = frag
|
||||
.blocks
|
||||
.into_iter()
|
||||
.find_map(|bl| match bl {
|
||||
Block::Para(p) => Some(p),
|
||||
Block::Table(_) => None,
|
||||
})
|
||||
.unwrap_or_else(|| Paragraph::with_text("", CharProps::default()));
|
||||
para.section = None;
|
||||
rows.push(row_text(s, &a, &b));
|
||||
blocks.push(Block::Para(para));
|
||||
}
|
||||
s.clipboard_text = rows.join("\n");
|
||||
s.clipboard = Some(Fragment { blocks, parts });
|
||||
Ok(json!({"text": s.clipboard_text}))
|
||||
}
|
||||
|
||||
/// Delete each row's piece (bottom up, so earlier positions stay valid) and leave the caret at
|
||||
/// the block's top-left corner.
|
||||
fn delete(s: &mut Session, segs: &[(Pos, Pos)]) -> Result<(), CmdError> {
|
||||
for (a, b) in segs.iter().rev() {
|
||||
let (a, b) = (s.doc.clamp(a), s.doc.clamp(b));
|
||||
if a == b || a.path != b.path || a.story != b.story {
|
||||
continue;
|
||||
}
|
||||
if s.doc.settings.track_changes {
|
||||
super::track_delete(s, &a, &b)?;
|
||||
} else {
|
||||
s.doc.delete_range(&a, &b)?;
|
||||
}
|
||||
}
|
||||
let at = segs.first().map(|(a, _)| s.doc.clamp(a)).unwrap_or_else(|| s.sel.focus.clone());
|
||||
s.sel = Selection::caret(at);
|
||||
s.column = None;
|
||||
s.column_mode = false;
|
||||
s.goal_x = None;
|
||||
s.pending = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a selection command once per non-empty row, then restore the block.
|
||||
fn per_row(s: &mut Session, run: fn(&mut Session, &Value) -> CmdResult, v: &Value, segs: &[(Pos, Pos)]) -> CmdResult {
|
||||
let block = s.column.take();
|
||||
for (a, b) in segs.iter().rev() {
|
||||
let (a, b) = (s.doc.clamp(a), s.doc.clamp(b));
|
||||
if a == b || a.path != b.path {
|
||||
continue;
|
||||
}
|
||||
s.sel = Selection { anchor: a, focus: b };
|
||||
if let Err(e) = run(s, v) {
|
||||
s.column = block;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
if let Some(c) = &block {
|
||||
s.sel = c.sel.clone();
|
||||
}
|
||||
s.column = block;
|
||||
result(s)
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::edit::Fragment;
|
||||
use wordcraft_doc::{Block, Pos, StoryRef};
|
||||
use wordcraft_doc::{Pos, StoryRef};
|
||||
|
||||
use super::{delete_selection, pos_json, sel_result};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
|
@ -32,10 +32,38 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
.params(r#"{"text"?: string}"#)
|
||||
.key("Mod+Shift+Alt+V"),
|
||||
CommandSpec::new("edit.pasteMerge", "Paste: Merge Formatting", "Home › Clipboard › Paste", paste_text).params(r#"{"text"?: string}"#),
|
||||
CommandSpec::new("edit.clipboardPane", "Clipboard Pane", "Home › Clipboard", |s, v| {
|
||||
let on = p::bool(v, "value").unwrap_or(!s.view.clipboard_pane);
|
||||
s.view.clipboard_pane = on;
|
||||
Ok(json!({"value": on, "count": s.clip_history.len()}))
|
||||
})
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("edit.clipboardItems", "Clipboard Items", "Home › Clipboard › Clipboard Pane", |s, _| Ok(s.clip_history.describe())).pure(),
|
||||
CommandSpec::new("edit.pasteClipboardItem", "Paste Clipboard Item", "Home › Clipboard › Clipboard Pane", paste_clip_item)
|
||||
.params(r#"{"index": n (0 = newest)}"#)
|
||||
.when(has_clips),
|
||||
CommandSpec::new("edit.pasteAllClipboard", "Paste All", "Home › Clipboard › Clipboard Pane", paste_all_clips).when(has_clips),
|
||||
CommandSpec::new("edit.deleteClipboardItem", "Delete Clipboard Item", "Home › Clipboard › Clipboard Pane", |s, v| {
|
||||
let i = clip_index(s, v)?;
|
||||
s.clip_history.items.remove(i);
|
||||
Ok(s.clip_history.describe())
|
||||
})
|
||||
.params(r#"{"index": n (0 = newest)}"#)
|
||||
.when(has_clips)
|
||||
.pure(),
|
||||
CommandSpec::new("edit.clearClipboard", "Clear All", "Home › Clipboard › Clipboard Pane", |s, _| {
|
||||
s.clip_history.items.clear();
|
||||
Ok(s.clip_history.describe())
|
||||
})
|
||||
.pure(),
|
||||
CommandSpec::new("edit.find", "Find", "Home › Editing", find)
|
||||
.key("Mod+F")
|
||||
.params(r#"{"text": string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("edit.advancedFind", "Advanced Find", "Home › Editing › Find", advanced_find)
|
||||
.params(r#"{"text"?: string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool, "in"?: "main|selection", "highlight"?: bool}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("edit.findNext", "Find Next", "Home › Editing › Find", |s, _| step(s, 1)).key("Mod+G / F3").pure(),
|
||||
CommandSpec::new("edit.findPrevious", "Find Previous", "Home › Editing › Find", |s, _| step(s, -1)).key("Mod+Shift+G").pure(),
|
||||
CommandSpec::new("edit.replace", "Replace", "Home › Editing", replace).key("Mod+H").params(r#"{"text": string, "with": string}"#),
|
||||
|
|
@ -63,6 +91,7 @@ fn copy(s: &mut Session, _: &Value) -> CmdResult {
|
|||
}
|
||||
let f = s.doc.copy_range(&a, &b);
|
||||
s.clipboard_text = f.plain_text();
|
||||
s.clip_history.push(&f, &s.clipboard_text);
|
||||
s.clipboard = Some(f);
|
||||
Ok(json!({"text": s.clipboard_text}))
|
||||
}
|
||||
|
|
@ -81,24 +110,142 @@ fn paste(s: &mut Session, v: &Value) -> CmdResult {
|
|||
(None, Some(f)) => f.clone(),
|
||||
(None, None) => return Err(CmdError::Failed("the clipboard is empty".into())),
|
||||
};
|
||||
if let Some(images) = v.get("image").and_then(Value::as_str) {
|
||||
let _ = images;
|
||||
}
|
||||
let at = delete_selection(s)?;
|
||||
let mut frag = frag;
|
||||
if s.doc.settings.track_changes {
|
||||
let rid = super::new_revision(s, wordcraft_doc::RevisionKind::Insert);
|
||||
for b in &mut frag.blocks {
|
||||
if let Block::Para(p) = b {
|
||||
for r in &mut p.runs {
|
||||
r.props.ins = Some(rid);
|
||||
super::paste::insert(s, frag)?;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Most items the Clipboard pane keeps.
|
||||
pub const CLIP_MAX_ITEMS: usize = 24;
|
||||
/// A copy bigger than this (estimated) is not collected (it still reaches the clipboard).
|
||||
const CLIP_ITEM_BYTES: usize = 4 << 20;
|
||||
/// All collected items together stay under this; the oldest go first.
|
||||
const CLIP_TOTAL_BYTES: usize = 24 << 20;
|
||||
/// Characters of preview per item.
|
||||
const CLIP_PREVIEW_CHARS: usize = 120;
|
||||
|
||||
/// One item collected by Copy or Cut.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ClipItem {
|
||||
pub fragment: Fragment,
|
||||
/// Its plain text.
|
||||
pub text: String,
|
||||
/// Rough memory use.
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl ClipItem {
|
||||
/// One line of the item's text for a list: whitespace runs become one space, objects drop out.
|
||||
pub fn preview(&self) -> String {
|
||||
let words = self.text.split(|c: char| c.is_whitespace() || c == wordcraft_doc::para::OBJ).filter(|w| !w.is_empty());
|
||||
let mut out = String::new();
|
||||
let mut n = 0;
|
||||
for w in words {
|
||||
if !out.is_empty() {
|
||||
out.push(' ');
|
||||
n += 1;
|
||||
}
|
||||
for c in w.chars() {
|
||||
if n >= CLIP_PREVIEW_CHARS {
|
||||
out.push('…');
|
||||
return out;
|
||||
}
|
||||
out.push(c);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
let end = s.doc.insert_fragment(&at, &frag)?;
|
||||
s.sel = Selection::caret(end);
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Items collected by Copy and Cut during the session, newest first: the Clipboard pane
|
||||
/// (Home › Clipboard). At most [`CLIP_MAX_ITEMS`]; oversized copies are skipped.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ClipHistory {
|
||||
items: Vec<ClipItem>,
|
||||
}
|
||||
|
||||
impl ClipHistory {
|
||||
pub fn items(&self) -> &[ClipItem] {
|
||||
&self.items
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
|
||||
fn push(&mut self, f: &Fragment, text: &str) {
|
||||
// Text plus per-paragraph overhead; pictures are references into the document.
|
||||
let paras = text.matches('\n').count().saturating_add(f.blocks.len()).saturating_add(f.parts.len()).saturating_add(1);
|
||||
let bytes = text.len().saturating_mul(2).saturating_add(paras.saturating_mul(256));
|
||||
if bytes > CLIP_ITEM_BYTES || f.blocks.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Copying the same thing again doesn't add a second item.
|
||||
if self.items.first().is_some_and(|i| i.text == text && i.fragment == *f) {
|
||||
return;
|
||||
}
|
||||
self.items.insert(0, ClipItem { fragment: f.clone(), text: text.to_string(), bytes });
|
||||
self.items.truncate(CLIP_MAX_ITEMS);
|
||||
while self.items.len() > 1 && self.items.iter().map(|i| i.bytes).sum::<usize>() > CLIP_TOTAL_BYTES {
|
||||
self.items.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// The items for agents and the pane, newest first.
|
||||
fn describe(&self) -> Value {
|
||||
Value::Array(
|
||||
self.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, it)| {
|
||||
json!({
|
||||
"index": i,
|
||||
"preview": it.preview(),
|
||||
"chars": it.text.chars().count(),
|
||||
"objects": it.text.matches(wordcraft_doc::para::OBJ).count(),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn has_clips(s: &Session) -> Option<&'static str> {
|
||||
if s.clip_history.is_empty() { Some("the Clipboard pane is empty") } else { None }
|
||||
}
|
||||
|
||||
fn clip_index(s: &Session, v: &Value) -> Result<usize, CmdError> {
|
||||
let i = p::u64(v, "index").ok_or_else(|| CmdError::Params("`index` (number, 0 = newest) is required".into()))?;
|
||||
usize::try_from(i)
|
||||
.ok()
|
||||
.filter(|i| *i < s.clip_history.len())
|
||||
.ok_or_else(|| CmdError::Params(format!("no clipboard item {i} (there are {})", s.clip_history.len())))
|
||||
}
|
||||
|
||||
/// Paste a collected item through the normal paste path. The clipboard itself stays as it was.
|
||||
fn paste_clip(s: &mut Session, f: Fragment) -> CmdResult {
|
||||
let saved = s.clipboard.replace(f);
|
||||
let r = paste(s, &json!({}));
|
||||
s.clipboard = saved;
|
||||
r
|
||||
}
|
||||
|
||||
fn paste_clip_item(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let i = clip_index(s, v)?;
|
||||
let f = s.clip_history.items.get(i).map(|x| x.fragment.clone()).ok_or_else(|| CmdError::Params("no such clipboard item".into()))?;
|
||||
paste_clip(s, f)
|
||||
}
|
||||
|
||||
/// Every collected item, in the order they were copied (one undo step).
|
||||
fn paste_all_clips(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let all: Vec<Fragment> = s.clip_history.items.iter().rev().map(|x| x.fragment.clone()).collect();
|
||||
let mut r = sel_result(s)?;
|
||||
for f in all {
|
||||
r = paste_clip(s, f)?;
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn paste_text(s: &mut Session, v: &Value) -> CmdResult {
|
||||
|
|
@ -115,7 +262,7 @@ fn paste_text(s: &mut Session, v: &Value) -> CmdResult {
|
|||
/// paragraph without it: a match lies within one stretch of live text, and word
|
||||
/// boundaries and anchors see the neighbouring live text, not the deleted text.
|
||||
/// Otherwise Replace would find its own deleted text again.
|
||||
fn search(s: &Session, story: StoryRef) -> Result<Vec<(Pos, Pos)>, CmdError> {
|
||||
pub(crate) fn search(s: &Session, story: StoryRef) -> Result<Vec<(Pos, Pos)>, CmdError> {
|
||||
use regex_automata::{Input, meta, util::syntax};
|
||||
let f = &s.find;
|
||||
if f.query.is_empty() {
|
||||
|
|
@ -160,6 +307,8 @@ fn search(s: &Session, story: StoryRef) -> Result<Vec<(Pos, Pos)>, CmdError> {
|
|||
}
|
||||
|
||||
fn read_opts(s: &mut Session, v: &Value) {
|
||||
// A new search changes what Reading Highlight marks.
|
||||
s.find.highlights = None;
|
||||
if let Some(t) = p::str(v, "text") {
|
||||
s.find.query = t.to_string();
|
||||
}
|
||||
|
|
@ -199,6 +348,35 @@ fn find(s: &mut Session, v: &Value) -> CmdResult {
|
|||
}))
|
||||
}
|
||||
|
||||
/// Find in the classic dialog: every match in the body or the selection, optionally highlighted while you read.
|
||||
fn advanced_find(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if p::str(v, "text").is_none() && v.get("highlight").is_none() {
|
||||
s.ui_requests.push(json!({"open": "find"}));
|
||||
return Ok(json!({"count": s.find.results.len()}));
|
||||
}
|
||||
read_opts(s, v);
|
||||
let within = match p::str(v, "in").unwrap_or("main") {
|
||||
"main" => None,
|
||||
"selection" if !s.sel.is_collapsed() => Some(s.sel.ordered()),
|
||||
"selection" => return Err(CmdError::Params("`in: selection` needs a selection".into())),
|
||||
other => return Err(CmdError::Params(format!("`in` is main or selection, not `{other}`"))),
|
||||
};
|
||||
let story = within.as_ref().map(|(a, _)| a.story).unwrap_or(StoryRef::Body);
|
||||
let mut found = search(s, story)?;
|
||||
if let Some((a, b)) = &within {
|
||||
found.retain(|(x, y)| x >= a && y <= b);
|
||||
}
|
||||
if let Some(h) = p::bool(v, "highlight") {
|
||||
s.find.highlight = h;
|
||||
s.find.highlights = None;
|
||||
}
|
||||
let n = found.len();
|
||||
let matches: Vec<Value> = found.iter().take(200).map(|(a, b)| json!({"start": pos_json(a), "end": pos_json(b)})).collect();
|
||||
s.find.current = 0;
|
||||
s.find.results = found;
|
||||
Ok(json!({"count": n, "matches": matches, "highlight": s.find.highlight}))
|
||||
}
|
||||
|
||||
fn step(s: &mut Session, dir: i64) -> CmdResult {
|
||||
let story = s.sel.focus.story;
|
||||
s.find.results = search(s, story)?;
|
||||
|
|
|
|||
|
|
@ -131,12 +131,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
|
|||
return Ok(json!({"saved": false}));
|
||||
}
|
||||
};
|
||||
s.doc.core.modified = super::now_iso();
|
||||
if s.doc.core.created.is_empty() {
|
||||
s.doc.core.created = s.doc.core.modified.clone();
|
||||
}
|
||||
s.doc.core.last_modified_by = s.author.clone();
|
||||
s.doc.core.revision = s.doc.core.revision.saturating_add(1);
|
||||
s.stamp_save();
|
||||
crate::io::save_path(&path, &s.doc).map_err(CmdError::Failed)?;
|
||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||
if ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json"].contains(&ext.as_str()) {
|
||||
|
|
|
|||
|
|
@ -160,8 +160,28 @@ fn target(s: &Session) -> Option<(Pos, Pos)> {
|
|||
None
|
||||
}
|
||||
|
||||
/// The non-empty rows of a column selection, if one is active.
|
||||
fn column_rows(s: &Session) -> Option<Vec<(Pos, Pos)>> {
|
||||
let segs = s.column_segments()?;
|
||||
let rows: Vec<(Pos, Pos)> = segs
|
||||
.iter()
|
||||
.map(|(a, b)| (s.doc.clamp(a), s.doc.clamp(b)))
|
||||
.filter(|(a, b)| a != b && a.path == b.path && a.story == b.story)
|
||||
.map(|(a, b)| if a <= b { (a, b) } else { (b, a) })
|
||||
.collect();
|
||||
// An empty block (column mode just started) formats like a caret.
|
||||
(!rows.is_empty()).then_some(rows)
|
||||
}
|
||||
|
||||
/// Apply a change to the selection (or the pending caret formatting).
|
||||
pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
|
||||
// A column selection formats each row's piece.
|
||||
if let Some(rows) = column_rows(s) {
|
||||
for (a, b) in rows {
|
||||
s.doc.format_range(&a, &b, f)?;
|
||||
}
|
||||
return sel_result(s);
|
||||
}
|
||||
match target(s) {
|
||||
Some((a, b)) => {
|
||||
s.doc.format_range(&a, &b, f)?;
|
||||
|
|
@ -186,6 +206,22 @@ pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
|
|||
/// Resolved formatting of every character in the target (or the caret).
|
||||
fn resolved(s: &Session) -> Vec<ResolvedChar> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(rows) = column_rows(s) {
|
||||
for (a, b) in rows {
|
||||
let Some(p) = s.doc.para_at(&a) else { continue };
|
||||
for (r, c) in p.run_ranges() {
|
||||
if r.end > a.off && r.start < b.off {
|
||||
out.push(s.doc.styles.resolve_char(p.props.style.as_deref(), c));
|
||||
}
|
||||
}
|
||||
if out.len() > 10_000 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !out.is_empty() {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
let Some((a, b)) = target(s) else {
|
||||
let f = &s.sel.focus;
|
||||
let style = s.doc.para_at(f).and_then(|p| p.props.style.clone());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
//! Style Inspector (Home › Styles): the paragraph style and the character style at the caret,
|
||||
//! the direct formatting layered on each, and a way to clear each level.
|
||||
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Map, Value, json};
|
||||
use wordcraft_doc::props::{CharProps, ParaProps};
|
||||
|
||||
use super::sel_result;
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
/// The levels `styles.inspectorClear` can reset.
|
||||
pub const LEVELS: [&str; 4] = ["paragraphStyle", "paragraphFormatting", "characterStyle", "characterFormatting"];
|
||||
|
||||
/// Direct character properties that aren't formatting (links, revisions, proofing, direction).
|
||||
const CHAR_SKIP: [&str; 7] = ["style", "link", "ins", "del", "lang", "noProof", "rtl"];
|
||||
/// Direct paragraph properties the inspector doesn't list (lists have their own UI).
|
||||
const PARA_SKIP: [&str; 2] = ["style", "numbering"];
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("styles.inspector", "Style Inspector", "Home › Styles", |s, v| {
|
||||
s.view.style_inspector = p::bool(v, "value").unwrap_or(!s.view.style_inspector);
|
||||
let mut out = inspect(s);
|
||||
if let Value::Object(m) = &mut out {
|
||||
m.insert("pane".into(), json!(s.view.style_inspector));
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.params(r#"{"value"?: bool (show/hide the pane)}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("styles.inspect", "Inspect Styles at Selection", "Home › Styles › Style Inspector", |s, _| Ok(inspect(s))).pure(),
|
||||
CommandSpec::new("styles.inspectorClear", "Clear Formatting Level", "Home › Styles › Style Inspector", clear)
|
||||
.params(r#"{"level": "paragraphStyle|paragraphFormatting|characterStyle|characterFormatting"}"#),
|
||||
]
|
||||
}
|
||||
|
||||
/// The paragraph props at the caret (the selection start for a selection).
|
||||
fn para_props(s: &Session) -> ParaProps {
|
||||
let (a, _) = s.sel.ordered();
|
||||
s.doc.para_at(&a).map(|p| p.props.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The direct character props at the caret (the first selected character for a selection).
|
||||
fn char_props(s: &Session) -> CharProps {
|
||||
let (a, b) = s.sel.ordered();
|
||||
if a == b {
|
||||
return s.typing_props();
|
||||
}
|
||||
match s.doc.para_at(&a) {
|
||||
Some(p) if a.off < p.len() => p.props_of_char(a.off).clone(),
|
||||
_ => s.typing_props(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Each set field of `direct` (minus `skip`) whose value changes the result of `resolve`, given
|
||||
/// a props value holding only that field. Returns `[{"prop", "value"}]` in field order.
|
||||
fn differences<T: Serialize + DeserializeOwned>(direct: &T, skip: &[&str], base: &Value, resolve: &dyn Fn(T) -> Value) -> Vec<Value> {
|
||||
let Ok(Value::Object(fields)) = serde_json::to_value(direct) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for (k, v) in fields {
|
||||
if skip.contains(&k.as_str()) || v.is_null() {
|
||||
continue;
|
||||
}
|
||||
let mut one = Map::new();
|
||||
one.insert(k.clone(), v.clone());
|
||||
let Ok(single) = serde_json::from_value::<T>(Value::Object(one)) else { continue };
|
||||
if resolve(single) != *base {
|
||||
out.push(json!({"prop": k, "value": v}));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn style_name(s: &Session, id: &str) -> String {
|
||||
s.doc.styles.get(id).map(|st| st.name.clone()).unwrap_or_else(|| id.to_string())
|
||||
}
|
||||
|
||||
/// What the Style Inspector shows, as JSON.
|
||||
pub fn inspect(s: &Session) -> Value {
|
||||
let pp = para_props(s);
|
||||
let pstyle = pp.style.clone().unwrap_or_else(|| "Normal".into());
|
||||
let styles = &s.doc.styles;
|
||||
let para_base = ParaProps { style: Some(pstyle.clone()), ..Default::default() };
|
||||
let pbase = serde_json::to_value(styles.resolve_para(¶_base)).unwrap_or(Value::Null);
|
||||
let pdiff = differences(&pp, &PARA_SKIP, &pbase, &|mut one: ParaProps| {
|
||||
one.style = Some(pstyle.clone());
|
||||
serde_json::to_value(styles.resolve_para(&one)).unwrap_or(Value::Null)
|
||||
});
|
||||
|
||||
let cp = char_props(s);
|
||||
let cstyle = cp.style.clone();
|
||||
let char_base = CharProps { style: cstyle.clone(), ..Default::default() };
|
||||
let cbase = serde_json::to_value(styles.resolve_char(Some(&pstyle), &char_base)).unwrap_or(Value::Null);
|
||||
let cdiff = differences(&cp, &CHAR_SKIP, &cbase, &|mut one: CharProps| {
|
||||
one.style = cstyle.clone();
|
||||
serde_json::to_value(styles.resolve_char(Some(&pstyle), &one)).unwrap_or(Value::Null)
|
||||
});
|
||||
|
||||
json!({
|
||||
"paragraph": {
|
||||
"style": pstyle,
|
||||
"styleName": style_name(s, &pstyle),
|
||||
"direct": pdiff,
|
||||
},
|
||||
"character": {
|
||||
"style": cstyle,
|
||||
"styleName": cstyle.as_deref().map(|c| style_name(s, c)),
|
||||
"direct": cdiff,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn clear(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let level = p::req_str(v, "level")?;
|
||||
let (a, b) = s.sel.ordered();
|
||||
match level {
|
||||
"paragraphStyle" => {
|
||||
// Reset to Normal, keeping direct formatting.
|
||||
s.doc.format_paragraphs(&a, &b, &|p| p.style = None)?;
|
||||
}
|
||||
"paragraphFormatting" => {
|
||||
s.doc.format_paragraphs(&a, &b, &|p| {
|
||||
*p = ParaProps { style: p.style.clone(), numbering: p.numbering, ..Default::default() };
|
||||
})?;
|
||||
}
|
||||
"characterStyle" => return super::format::apply(s, &|c| c.style = None),
|
||||
"characterFormatting" => {
|
||||
return super::format::apply(s, &|c| {
|
||||
*c = CharProps {
|
||||
style: c.style.clone(),
|
||||
link: c.link.clone(),
|
||||
ins: c.ins,
|
||||
del: c.del,
|
||||
lang: c.lang.clone(),
|
||||
no_proof: c.no_proof,
|
||||
rtl: c.rtl,
|
||||
..Default::default()
|
||||
};
|
||||
});
|
||||
}
|
||||
other => return Err(CmdError::Params(format!("unknown level `{other}`; expected one of {}", LEVELS.join(", ")))),
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
|
@ -2,20 +2,24 @@
|
|||
|
||||
pub mod caret;
|
||||
pub mod citations;
|
||||
pub mod column;
|
||||
pub mod design;
|
||||
pub mod edit;
|
||||
pub mod equation;
|
||||
pub mod file;
|
||||
pub mod format;
|
||||
pub mod insert;
|
||||
pub mod inspector;
|
||||
pub mod mailings;
|
||||
pub mod objects;
|
||||
pub mod page;
|
||||
pub mod para;
|
||||
pub mod paste;
|
||||
pub mod references;
|
||||
pub mod review;
|
||||
pub mod speech;
|
||||
pub mod table;
|
||||
pub mod table_style;
|
||||
pub mod text;
|
||||
pub mod tools;
|
||||
pub mod view;
|
||||
|
|
@ -31,14 +35,18 @@ pub fn registry() -> Registry {
|
|||
let mut v = Vec::new();
|
||||
v.extend(text::specs());
|
||||
v.extend(caret::specs());
|
||||
v.extend(column::specs());
|
||||
v.extend(edit::specs());
|
||||
v.extend(paste::specs());
|
||||
v.extend(format::specs());
|
||||
v.extend(para::specs());
|
||||
v.extend(inspector::specs());
|
||||
v.extend(view::specs());
|
||||
v.extend(insert::specs());
|
||||
v.extend(equation::specs());
|
||||
v.extend(page::specs());
|
||||
v.extend(table::specs());
|
||||
v.extend(table_style::specs());
|
||||
v.extend(review::specs());
|
||||
v.extend(file::specs());
|
||||
v.extend(design::specs());
|
||||
|
|
@ -116,7 +124,7 @@ pub fn join_next_para(s: &mut Session, story: StoryRef, path: &wordcraft_doc::Pa
|
|||
}
|
||||
|
||||
/// Tracked deletion: own insertions are removed, other text is marked deleted.
|
||||
fn track_delete(s: &mut Session, a: &Pos, b: &Pos) -> Result<Pos, CmdError> {
|
||||
pub(crate) fn track_delete(s: &mut Session, a: &Pos, b: &Pos) -> Result<Pos, CmdError> {
|
||||
let rid = new_revision(s, RevisionKind::Delete);
|
||||
let author = s.author.clone();
|
||||
// Remove text this author inserted (it never existed for the reader); mark the rest.
|
||||
|
|
|
|||
|
|
@ -22,10 +22,19 @@ fn has_object(s: &Session) -> Option<&'static str> {
|
|||
}
|
||||
fn has_floating(s: &Session) -> Option<&'static str> {
|
||||
match selected(s) {
|
||||
Some((_, InlineObject::Image { float, .. } | InlineObject::Shape { float, .. })) if float.wrap != Wrap::Inline => None,
|
||||
Some((_, o)) if o.is_floating() => None,
|
||||
_ => Some("select a floating picture or shape first"),
|
||||
}
|
||||
}
|
||||
fn has_group(s: &Session) -> Option<&'static str> {
|
||||
match selected(s) {
|
||||
Some((_, InlineObject::Group { .. })) => None,
|
||||
_ => Some("select a group first"),
|
||||
}
|
||||
}
|
||||
fn can_group(s: &Session) -> Option<&'static str> {
|
||||
if picked_objects(s).len() >= 2 { None } else { Some("select two or more pictures or shapes first (Shift+click)") }
|
||||
}
|
||||
fn has_shape(s: &Session) -> Option<&'static str> {
|
||||
match selected(s) {
|
||||
Some((_, InlineObject::Shape { .. })) => None,
|
||||
|
|
@ -260,6 +269,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
})
|
||||
.params(r#"{"dx"?: pt, "dy"?: pt}"#)
|
||||
.when(has_floating),
|
||||
CommandSpec::new("arrange.group", "Group", "Layout › Arrange", group)
|
||||
.params(r#"{} (groups the selected pictures, shapes and text boxes: the selected one plus those added with select.addObject)"#)
|
||||
.when(can_group),
|
||||
CommandSpec::new("arrange.ungroup", "Ungroup", "Layout › Arrange", ungroup).when(has_group),
|
||||
CommandSpec::new("select.addObject", "Add Object to Selection", "Home › Editing › Select", add_object)
|
||||
.params(r#"{"pos"?: Pos, "index"?: n, "objects"?: [Pos | n]} (n: its Selection Pane index; with no object selected yet, the first one becomes the selection)"#)
|
||||
.pure(),
|
||||
CommandSpec::new("select.objects", "Select Objects", "Home › Editing › Select", |s, v| {
|
||||
let n = p::u64(v, "index").unwrap_or(0) as usize;
|
||||
let list = all_objects(s);
|
||||
|
|
@ -320,7 +336,7 @@ pub fn object_selection(s: &Session) -> Option<(Pos, &InlineObject)> {
|
|||
return None;
|
||||
}
|
||||
let o = s.doc.para_at(&a)?.object_at(a.off)?;
|
||||
matches!(o, InlineObject::Image { .. } | InlineObject::Shape { .. }).then_some((a, o))
|
||||
o.is_drawing().then_some((a, o))
|
||||
}
|
||||
|
||||
/// The first picture/shape in the selection, or just before a collapsed caret.
|
||||
|
|
@ -347,7 +363,7 @@ pub fn selected_all(s: &Session, max: usize) -> Vec<(Pos, InlineObject)> {
|
|||
if !inside {
|
||||
continue;
|
||||
}
|
||||
if let Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. })) = p.object_at(off) {
|
||||
if let Some(o) = p.object_at(off).filter(|o| o.is_drawing()) {
|
||||
out.push((Pos { story, path: path.clone(), off }, o.clone()));
|
||||
if out.len() >= max {
|
||||
return out;
|
||||
|
|
@ -410,8 +426,14 @@ fn shape_effects(s: &mut Session, v: &Value) -> CmdResult {
|
|||
Some(n @ Value::Number(_)) => Some(n.as_f64().map(|x| x as f32)),
|
||||
Some(_) => return Err(CmdError::Params("`softEdge`: a radius in points or null".into())),
|
||||
};
|
||||
let targets: Vec<Pos> =
|
||||
// The shapes in the selection and those added to it (Shift+click).
|
||||
let mut targets: Vec<Pos> =
|
||||
selected_all(s, MAX_EFFECT_SHAPES).into_iter().filter(|(_, o)| matches!(o, InlineObject::Shape { .. })).map(|(pos, _)| pos).collect();
|
||||
for pos in picked_objects(s) {
|
||||
if !targets.contains(&pos) && matches!(s.doc.para_at(&pos).and_then(|q| q.object_at(pos.off)), Some(InlineObject::Shape { .. })) {
|
||||
targets.push(pos);
|
||||
}
|
||||
}
|
||||
if targets.is_empty() {
|
||||
return Err(CmdError::Disabled("no shape selected".into()));
|
||||
}
|
||||
|
|
@ -462,12 +484,7 @@ fn bounds(s: &mut Session, v: &Value) -> CmdResult {
|
|||
let w = p::f32(v, "width").unwrap_or(w0).clamp(min, MAX_OFFSET);
|
||||
let h = p::f32(v, "height").unwrap_or(h0).clamp(min, MAX_OFFSET);
|
||||
if (w, h) != (w0, h0) {
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Image { w: ow, h: oh, .. } | InlineObject::Shape { w: ow, h: oh, .. } = o {
|
||||
*ow = w;
|
||||
*oh = h;
|
||||
}
|
||||
})?;
|
||||
with_obj(s, |o| o.set_size(w, h))?;
|
||||
}
|
||||
let pos = match to {
|
||||
Some((x, y)) => move_object(s, pos, p::u64(v, "page"), x, y, (w, h))?,
|
||||
|
|
@ -547,7 +564,7 @@ fn move_object(s: &mut Session, pos: Pos, page: Option<u64>, x: f32, y: f32, (w,
|
|||
fn unalign(s: &mut Session) -> Result<(), CmdError> {
|
||||
use wordcraft_doc::para::Anchor;
|
||||
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?;
|
||||
let (InlineObject::Image { float, .. } | InlineObject::Shape { float, .. }) = &obj else { return Ok(()) };
|
||||
let Some((_, _, float)) = obj.frame() else { return Ok(()) };
|
||||
if float.h_align.is_none() && float.v_align.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -596,10 +613,7 @@ fn nearest_line(layout: &wordcraft_layout::DocLayout, page: usize, y: f32) -> Op
|
|||
}
|
||||
|
||||
fn obj_size(o: &InlineObject) -> (f32, f32) {
|
||||
match o {
|
||||
InlineObject::Image { w, h, .. } | InlineObject::Shape { w, h, .. } => (*w, *h),
|
||||
_ => (0.0, 0.0),
|
||||
}
|
||||
o.frame().map_or((0.0, 0.0), |(w, h, _)| (w, h))
|
||||
}
|
||||
|
||||
fn with_obj(s: &mut Session, f: impl Fn(&mut InlineObject)) -> CmdResult {
|
||||
|
|
@ -620,15 +634,15 @@ fn edit_obj(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Result
|
|||
|
||||
fn edit_float(s: &mut Session, pos: &Pos, f: impl Fn(&mut Float)) -> Result<InlineObject, CmdError> {
|
||||
edit_obj(s, pos, |o| {
|
||||
if let InlineObject::Image { float, .. } | InlineObject::Shape { float, .. } = o {
|
||||
if let Some(float) = o.float_mut() {
|
||||
f(float);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn with_float(s: &mut Session, f: impl Fn(&mut Float)) -> CmdResult {
|
||||
with_obj(s, |o| match o {
|
||||
InlineObject::Image { float, .. } | InlineObject::Shape { float, .. } => {
|
||||
with_obj(s, |o| {
|
||||
if let Some(float) = o.float_mut() {
|
||||
let was_inline = float.wrap == Wrap::Inline;
|
||||
f(float);
|
||||
// Word's distance from text for a newly wrapped object: 0.125" at the sides.
|
||||
|
|
@ -636,7 +650,6 @@ fn with_float(s: &mut Session, f: impl Fn(&mut Float)) -> CmdResult {
|
|||
float.dist = 9.0;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -836,12 +849,188 @@ fn picture_style(img: image::DynamicImage, style: &str) -> image::DynamicImage {
|
|||
}
|
||||
}
|
||||
|
||||
/// The selected pictures, shapes, text boxes and groups: the one the selection holds, then
|
||||
/// those added to it ([`Session::also_selected`]), each once, in the body.
|
||||
pub fn picked_objects(s: &Session) -> Vec<Pos> {
|
||||
let Some((first, _)) = object_selection(s) else { return Vec::new() };
|
||||
let mut out = vec![first];
|
||||
for p in &s.also_selected {
|
||||
if !out.contains(p) && s.doc.para_at(p).and_then(|q| q.object_at(p.off)).is_some_and(InlineObject::is_drawing) {
|
||||
out.push(p.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The object a `select.addObject` item names: a position, or a Selection Pane index.
|
||||
fn object_ref(s: &Session, v: &Value) -> Result<Pos, CmdError> {
|
||||
let pos = match v.as_u64() {
|
||||
Some(n) => all_objects(s).into_iter().nth(usize::try_from(n).unwrap_or(usize::MAX)).map(|(p, _)| p),
|
||||
None => super::parse_pos(v),
|
||||
};
|
||||
pos.filter(|p| p.story == StoryRef::Body && s.doc.para_at(p).and_then(|q| q.object_at(p.off)).is_some_and(InlineObject::is_drawing))
|
||||
.ok_or_else(|| CmdError::Params("no picture or shape there".into()))
|
||||
}
|
||||
|
||||
/// Add objects to the selection (Shift+click): the first one is selected if no object is yet;
|
||||
/// one already selected is taken out again.
|
||||
fn add_object(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let mut items: Vec<Value> = match v.get("objects").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().take(1000).cloned().collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
if let Some(p) = v.get("pos") {
|
||||
items.push(p.clone());
|
||||
}
|
||||
if let Some(i) = v.get("index") {
|
||||
items.push(i.clone());
|
||||
}
|
||||
if items.is_empty() {
|
||||
return Err(CmdError::Params("`pos`, `index` or `objects` required".into()));
|
||||
}
|
||||
for it in &items {
|
||||
let pos = object_ref(s, it)?;
|
||||
match object_selection(s).map(|(p, _)| p) {
|
||||
None => {
|
||||
s.also_selected.clear();
|
||||
s.sel = Selection { anchor: pos.clone(), focus: Pos { off: pos.off + wordcraft_doc::para::OBJ.len_utf8(), ..pos } };
|
||||
}
|
||||
Some(first) if first == pos => {}
|
||||
Some(_) => {
|
||||
if let Some(i) = s.also_selected.iter().position(|p| *p == pos) {
|
||||
s.also_selected.remove(i);
|
||||
} else {
|
||||
s.also_selected.push(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"selected": picked_objects(s).iter().map(super::pos_json).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Group the selected objects (Layout › Arrange › Group): one floating group object, anchored
|
||||
/// where the first of them was, in the box around them all; they keep their places and sizes.
|
||||
/// A group among them is merged in (its members join the new group).
|
||||
fn group(s: &mut Session, _: &Value) -> CmdResult {
|
||||
use wordcraft_doc::para::GroupChild;
|
||||
let mut picks = picked_objects(s);
|
||||
if picks.len() < 2 {
|
||||
return Err(CmdError::Disabled("select two or more pictures or shapes first".into()));
|
||||
}
|
||||
picks.sort();
|
||||
let layout = s.layout();
|
||||
let mut found = Vec::new();
|
||||
for pos in &picks {
|
||||
if pos.story != StoryRef::Body || pos.path.depth() > 0 {
|
||||
return Err(CmdError::Disabled("only objects in the body text (not in tables) can be grouped".into()));
|
||||
}
|
||||
let obj = s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
|
||||
if !obj.is_floating() {
|
||||
return Err(CmdError::Disabled("objects in line with text can't be grouped: choose a text wrapping for them first".into()));
|
||||
}
|
||||
let hit = layout.object(pos, s.page_hint).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?;
|
||||
found.push((obj, hit));
|
||||
}
|
||||
let page = found.first().map(|(_, h)| h.page).unwrap_or(0);
|
||||
if found.iter().any(|(_, h)| h.page != page) {
|
||||
return Err(CmdError::Disabled("objects on different pages can't be grouped".into()));
|
||||
}
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
|
||||
for (_, h) in &found {
|
||||
(x0, y0) = (x0.min(h.rect.x), y0.min(h.rect.y));
|
||||
(x1, y1) = (x1.max(h.rect.right()), y1.max(h.rect.bottom()));
|
||||
}
|
||||
let (w, h) = ((x1 - x0).clamp(1.0, MAX_OFFSET), (y1 - y0).clamp(1.0, MAX_OFFSET));
|
||||
let mut children = Vec::new();
|
||||
let member = |obj: &InlineObject, r: [f32; 4]| {
|
||||
let mut obj = obj.clone();
|
||||
obj.set_size(r[2], r[3]);
|
||||
if let Some(f) = obj.float_mut() {
|
||||
*f = Float::default();
|
||||
}
|
||||
GroupChild { x: r[0] - x0, y: r[1] - y0, obj }
|
||||
};
|
||||
for (obj, hit) in &found {
|
||||
let r = hit.rect;
|
||||
match obj {
|
||||
InlineObject::Group { .. } => children.extend(obj.group_rects(r.x, r.y, r.w, r.h).into_iter().map(|(cr, c)| member(c, cr))),
|
||||
_ => children.push(member(obj, [r.x, r.y, r.w, r.h])),
|
||||
}
|
||||
}
|
||||
let float = found.first().and_then(|(o, _)| o.frame()).map(|(_, _, f)| *f).unwrap_or_default();
|
||||
let grouped = InlineObject::Group { w, h, float, ch_w: w, ch_h: h, children };
|
||||
// Take the members out, last first so the earlier positions hold, and put the group where
|
||||
// the first one was.
|
||||
let len = wordcraft_doc::para::OBJ.len_utf8();
|
||||
for pos in picks.iter().rev() {
|
||||
s.doc.delete_range(pos, &Pos { off: pos.off + len, ..pos.clone() })?;
|
||||
}
|
||||
let first = picks.first().cloned().ok_or_else(|| CmdError::Failed("nothing to group".into()))?;
|
||||
let at = s.doc.clamp(&first);
|
||||
s.doc.insert_object(&at, grouped, &wordcraft_doc::CharProps::default())?;
|
||||
s.touch();
|
||||
let pos = move_object(s, at, Some(page as u64), x0, y0, (w, h))?;
|
||||
s.also_selected.clear();
|
||||
s.sel = Selection { anchor: pos.clone(), focus: Pos { off: pos.off + len, ..pos.clone() } };
|
||||
let o = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned();
|
||||
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null), "pos": super::pos_json(&pos)}))
|
||||
}
|
||||
|
||||
/// Split the selected group back into its pictures and shapes (Layout › Arrange › Ungroup), in
|
||||
/// the places and sizes the group shows them, anchored where the group was. They stay
|
||||
/// selected, so Group puts them back together.
|
||||
fn ungroup(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("select a group first".into()))?;
|
||||
let InlineObject::Group { w, h, float, .. } = &obj else { return Err(CmdError::Disabled("select a group first".into())) };
|
||||
// Its offsets, explicit: the members are placed from the same anchor.
|
||||
let pos = if float.wrap == Wrap::Inline {
|
||||
if pos.story != StoryRef::Body || pos.path.depth() > 0 {
|
||||
return Err(CmdError::Disabled("only groups in the body text can be ungrouped in line with text".into()));
|
||||
}
|
||||
let hit = s.layout().object(&pos, s.page_hint).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?;
|
||||
move_object(s, pos, Some(hit.page as u64), hit.rect.x, hit.rect.y, (*w, *h))?
|
||||
} else {
|
||||
unalign(s)?;
|
||||
pos
|
||||
};
|
||||
let obj = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
|
||||
let Some((w, h, float)) = obj.frame() else { return Err(CmdError::Failed("object vanished".into())) };
|
||||
let float = *float;
|
||||
let members: Vec<InlineObject> = obj
|
||||
.group_rects(0.0, 0.0, w, h)
|
||||
.into_iter()
|
||||
.map(|([x, y, cw, ch], c)| {
|
||||
let mut c = c.clone();
|
||||
c.set_size(cw.max(min_size(&c)), ch.max(min_size(&c)));
|
||||
if let Some(f) = c.float_mut() {
|
||||
*f = Float { x: (float.x + x).clamp(-MAX_OFFSET, MAX_OFFSET), y: (float.y + y).clamp(-MAX_OFFSET, MAX_OFFSET), ..float };
|
||||
}
|
||||
c
|
||||
})
|
||||
.collect();
|
||||
if members.is_empty() {
|
||||
return Err(CmdError::Failed("the group is empty".into()));
|
||||
}
|
||||
let len = wordcraft_doc::para::OBJ.len_utf8();
|
||||
s.doc.delete_range(&pos, &Pos { off: pos.off + len, ..pos.clone() })?;
|
||||
let mut at = s.doc.clamp(&pos);
|
||||
let mut placed = Vec::new();
|
||||
for m in members {
|
||||
placed.push(at.clone());
|
||||
at = s.doc.insert_object(&at, m, &wordcraft_doc::CharProps::default())?;
|
||||
}
|
||||
let first = placed.first().cloned().unwrap_or(pos);
|
||||
s.sel = Selection { anchor: first.clone(), focus: Pos { off: first.off + len, ..first } };
|
||||
s.also_selected = placed.into_iter().skip(1).collect();
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
fn all_objects(s: &Session) -> Vec<(Pos, InlineObject)> {
|
||||
let mut v = Vec::new();
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
for off in p.object_offsets() {
|
||||
if let Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. })) = p.object_at(off) {
|
||||
if let Some(o) = p.object_at(off).filter(|o| o.is_drawing()) {
|
||||
v.push((Pos { story: StoryRef::Body, path: path.clone(), off }, o.clone()));
|
||||
}
|
||||
}
|
||||
|
|
@ -858,6 +1047,7 @@ fn objects_list(s: &mut Session, _: &Value) -> CmdResult {
|
|||
let (kind, name) = match &o {
|
||||
InlineObject::Image { alt, .. } => ("picture", if alt.is_empty() { format!("Picture {}", i + 1) } else { alt.clone() }),
|
||||
InlineObject::Shape { kind, .. } => ("shape", format!("{kind:?} {}", i + 1)),
|
||||
InlineObject::Group { .. } => ("group", format!("Group {}", i + 1)),
|
||||
_ => ("object", format!("Object {}", i + 1)),
|
||||
};
|
||||
json!({"index": i, "kind": kind, "name": name, "pos": super::pos_json(&pos), "size": obj_size(&o)})
|
||||
|
|
@ -961,6 +1151,66 @@ mod tests {
|
|||
assert!(s.run("picture.crop", &json!({"left": 0.1})).is_err());
|
||||
}
|
||||
|
||||
/// The page rectangle of the object at `pos`.
|
||||
fn rect_of(s: &mut Session, pos: &Pos) -> [f32; 4] {
|
||||
let r = s.layout().object(pos, 0).unwrap().rect;
|
||||
[r.x, r.y, r.w, r.h]
|
||||
}
|
||||
|
||||
fn near(a: [f32; 4], b: [f32; 4]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.5)
|
||||
}
|
||||
|
||||
/// Two floating shapes, Shift+clicked together and grouped: one object that moves as one with
|
||||
/// its members in place; ungrouping puts them back where the group showed them, still
|
||||
/// selected, so Group regroups them.
|
||||
#[test]
|
||||
fn group_moves_as_one_and_ungroups() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("document.setText", &json!({"text": "one\ntwo\nthree\nfour"})).unwrap();
|
||||
s.sel = Selection::caret(s.doc.start_of(StoryRef::Body));
|
||||
s.run("insert.shape", &json!({"kind": "rectangle", "width": 100, "height": 50})).unwrap();
|
||||
s.run("arrange.bounds", &json!({"page": 0, "x": 100, "y": 150})).unwrap();
|
||||
s.run("select.collapse", &json!({"end": true})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "ellipse", "width": 60, "height": 40})).unwrap();
|
||||
s.run("arrange.bounds", &json!({"page": 0, "x": 300, "y": 200})).unwrap();
|
||||
// One shape selected: nothing to group yet.
|
||||
assert!(s.run("arrange.group", &json!({})).is_err());
|
||||
let r = s.run("select.addObject", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(r["selected"].as_array().unwrap().len(), 2);
|
||||
let g = s.run("arrange.group", &json!({})).unwrap();
|
||||
let pos: Pos = serde_json::from_value(g["pos"].clone()).unwrap();
|
||||
let list = s.run("arrange.selectionPane", &json!({})).unwrap();
|
||||
assert_eq!(list.as_array().unwrap().len(), 1, "{list}");
|
||||
assert_eq!(list[0]["kind"], "group");
|
||||
assert!(s.also_selected.is_empty());
|
||||
assert!(near(rect_of(&mut s, &pos), [100.0, 150.0, 260.0, 90.0]), "{:?}", rect_of(&mut s, &pos));
|
||||
|
||||
// Moving the group moves its members with it.
|
||||
s.run("arrange.nudge", &json!({"dx": 20, "dy": 10})).unwrap();
|
||||
let (pos, obj) = selected(&s).unwrap();
|
||||
let [x, y, w, h] = rect_of(&mut s, &pos);
|
||||
let members: Vec<[f32; 4]> = obj.group_rects(x, y, w, h).into_iter().map(|(r, _)| r).collect();
|
||||
assert!(near(members[0], [120.0, 160.0, 100.0, 50.0]) && near(members[1], [320.0, 210.0, 60.0, 40.0]), "{members:?}");
|
||||
// Drawn there too: the page shows both shapes inside the group's frame.
|
||||
let shapes = s.layout().pages[0].items.iter().filter(|i| matches!(i, wordcraft_layout::Placed::Shape { .. })).count();
|
||||
assert_eq!(shapes, 2);
|
||||
|
||||
// Ungroup: two shapes again, where the group showed them, both still selected.
|
||||
s.run("arrange.ungroup", &json!({})).unwrap();
|
||||
let list = all_objects(&s);
|
||||
assert_eq!(list.len(), 2);
|
||||
let rects: Vec<[f32; 4]> = list.iter().map(|(p, _)| rect_of(&mut s, p)).collect();
|
||||
assert!(rects.iter().any(|r| near(*r, [120.0, 160.0, 100.0, 50.0])), "{rects:?}");
|
||||
assert!(rects.iter().any(|r| near(*r, [320.0, 210.0, 60.0, 40.0])), "{rects:?}");
|
||||
assert_eq!(picked_objects(&s).len(), 2);
|
||||
s.run("arrange.group", &json!({})).unwrap();
|
||||
assert_eq!(all_objects(&s).len(), 1);
|
||||
// Undo goes back step by step.
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(all_objects(&s).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_alt_and_crop_round_trip() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
|
|
|
|||
|
|
@ -129,6 +129,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
CommandSpec::new("para.widowControl", "Widow/Orphan Control", "Home › Paragraph › Line and Page Breaks", |s, v| {
|
||||
tog(s, v, |p| p.widow_control, |p, b| p.widow_control = Some(b))
|
||||
}),
|
||||
CommandSpec::new("para.asianTypography", "Asian Typography", "Home › Paragraph › Asian Typography", asian_typography).params(
|
||||
r#"{"kinsoku"?: bool (East Asian line-breaking rules), "wordWrap"?: bool (false: Latin words may break at any character), "overflowPunct"?: bool (hanging punctuation), "topLinePunct"?: bool (compress punctuation at the start of a line), "autoSpaceDE"?: bool (space between Asian and Latin text), "autoSpaceDN"?: bool (space between Asian text and numbers)} — flags left out stay as they are; returns the flags at the caret"#,
|
||||
),
|
||||
CommandSpec::new("para.tabs", "Tabs", "Home › Paragraph › Paragraph", |s, v| {
|
||||
let tabs: Vec<TabStop> = serde_json::from_value(v.get("tabs").cloned().unwrap_or(Value::Null)).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
if tabs.len() > 64 {
|
||||
|
|
@ -154,14 +157,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
CommandSpec::new("styles.create", "Create a Style", "Home › Styles", create_style).params(r#"{"name": string, "basedOn"?: string, "fromSelection"?: bool}"#),
|
||||
CommandSpec::new("styles.modify", "Modify Style", "Home › Styles", modify_style).params(r#"{"style": string, "chr"?: CharProps, "para"?: ParaProps, "name"?: string, "next"?: string}"#),
|
||||
CommandSpec::new("styles.updateToMatch", "Update Style to Match Selection", "Home › Styles", update_to_match).params(r#"{"style"?: string}"#),
|
||||
CommandSpec::new("styles.delete", "Delete Style", "Home › Styles", |s, v| {
|
||||
let id = style_id(s, p::req_str(v, "style")?)?;
|
||||
if s.doc.styles.get(&id).is_some_and(|st| st.builtin) {
|
||||
return Err(CmdError::Failed("built-in styles can't be deleted".into()));
|
||||
CommandSpec::new("styles.delete", "Delete Style", "Home › Styles", delete_style).params(r#"{"style": string (name or id; custom styles only)}"#),
|
||||
CommandSpec::new("styles.manage", "Manage Styles", "Home › Styles", |s, v| {
|
||||
let sort = p::str(v, "sort").unwrap_or("recommended");
|
||||
if !matches!(sort, "recommended" | "alphabetical") {
|
||||
return Err(CmdError::Params(format!("unknown sort `{sort}` (recommended|alphabetical)")));
|
||||
}
|
||||
s.doc.styles.styles.retain(|st| st.id != id);
|
||||
sel_result(s)
|
||||
}),
|
||||
let list = manage_list(s, sort == "alphabetical");
|
||||
s.ui_requests.push(json!({"open": "manageStyles"}));
|
||||
Ok(list)
|
||||
})
|
||||
.params(r#"{"sort"?: "recommended|alphabetical"} → [{id, name, type: "paragraph|character|linked|table", builtIn, inGallery, hidden, basedOn, priority, format}]"#)
|
||||
.pure(),
|
||||
CommandSpec::new("styles.setVisibility", "Show or Hide Style", "Home › Styles", set_visibility)
|
||||
.params(r#"{"style": string, "gallery"?: bool (Styles gallery, w:qFormat), "hidden"?: bool (hidden from style lists, w:semiHidden)}"#),
|
||||
CommandSpec::new("styles.list", "Styles", "Home › Styles", |s, _| {
|
||||
Ok(Value::Array(
|
||||
s.doc.styles.styles.iter().map(|st| json!({"id": st.id, "name": st.name, "kind": st.kind, "quick": st.quick, "basedOn": st.based_on})).collect(),
|
||||
|
|
@ -240,6 +249,42 @@ fn tog(s: &mut Session, v: &Value, get: fn(&ParaProps) -> Option<bool>, set: fn(
|
|||
fmt(s, &|p| set(p, on))
|
||||
}
|
||||
|
||||
/// Asian Typography (the Paragraph dialog's tab): sets the given flags on every selected
|
||||
/// paragraph and returns the resolved flags at the caret.
|
||||
fn asian_typography(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let flag = |k: &str| p::bool(v, k);
|
||||
let (kinsoku, word_wrap, overflow, top_line, de, dn) =
|
||||
(flag("kinsoku"), flag("wordWrap"), flag("overflowPunct"), flag("topLinePunct"), flag("autoSpaceDE"), flag("autoSpaceDN"));
|
||||
let mut out = if [kinsoku, word_wrap, overflow, top_line, de, dn].iter().any(Option::is_some) {
|
||||
fmt(s, &|p| {
|
||||
let set = |field: &mut Option<bool>, val: Option<bool>| {
|
||||
if val.is_some() {
|
||||
*field = val;
|
||||
}
|
||||
};
|
||||
set(&mut p.kinsoku, kinsoku);
|
||||
set(&mut p.word_wrap, word_wrap);
|
||||
set(&mut p.overflow_punct, overflow);
|
||||
set(&mut p.top_line_punct, top_line);
|
||||
set(&mut p.auto_space_de, de);
|
||||
set(&mut p.auto_space_dn, dn);
|
||||
})?
|
||||
} else {
|
||||
sel_result(s)?
|
||||
};
|
||||
let r = cur(s);
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
o.insert(
|
||||
"asianTypography".into(),
|
||||
json!({
|
||||
"kinsoku": r.kinsoku, "wordWrap": r.word_wrap, "overflowPunct": r.overflow_punct,
|
||||
"topLinePunct": r.top_line_punct, "autoSpaceDE": r.auto_space_de, "autoSpaceDN": r.auto_space_dn,
|
||||
}),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn indent(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (a, b) = s.sel.ordered();
|
||||
let paths = s.doc.paths_between(&a, &b);
|
||||
|
|
@ -584,6 +629,155 @@ fn update_to_match(s: &mut Session, v: &Value) -> CmdResult {
|
|||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Delete a custom style. Text that uses it falls back to the style it was based on (or the
|
||||
/// default style), styles based on it are rebased, and a custom character style linked to it
|
||||
/// goes with it. Built-in styles are refused.
|
||||
fn delete_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let id = style_id(s, p::req_str(v, "style")?)?;
|
||||
let Some(st) = s.doc.styles.get(&id).cloned() else { return Err(CmdError::Params(format!("no style `{id}`"))) };
|
||||
// Table styles: their tables take Table Grid and styles based on them keep their look.
|
||||
if st.kind == StyleKind::Table {
|
||||
let mut out = super::table_style::delete(s, &id)?;
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
let tables = o.get("tables").cloned().unwrap_or(json!(0));
|
||||
o.insert("deleted".into(), json!([id]));
|
||||
o.insert("restyled".into(), tables);
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
if st.builtin {
|
||||
return Err(CmdError::Failed(format!("`{}` is a built-in style and can't be deleted", st.name)));
|
||||
}
|
||||
let mut gone = vec![st.clone()];
|
||||
if let Some(partner) = st.linked.as_deref().and_then(|l| s.doc.styles.get(l))
|
||||
&& !partner.builtin
|
||||
&& partner.linked.as_deref() == Some(id.as_str())
|
||||
{
|
||||
gone.push(partner.clone());
|
||||
}
|
||||
let mut changed = 0;
|
||||
for g in &gone {
|
||||
s.doc.styles.remove(&g.id);
|
||||
// Fall back to the base only while it is a style of the same kind that still exists.
|
||||
let base = g.based_on.clone().filter(|b| s.doc.styles.get(b).is_some_and(|x| x.kind == g.kind));
|
||||
changed += s.doc.restyle(&g.id, base);
|
||||
}
|
||||
touch_all(s);
|
||||
Ok(json!({"deleted": gone.iter().map(|g| g.id.clone()).collect::<Vec<_>>(), "restyled": changed}))
|
||||
}
|
||||
|
||||
/// Styles gallery and style list visibility (`w:qFormat` / `w:semiHidden`).
|
||||
fn set_visibility(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let id = style_id(s, p::req_str(v, "style")?)?;
|
||||
let (gallery, hidden) = (p::bool(v, "gallery"), p::bool(v, "hidden"));
|
||||
if gallery.is_none() && hidden.is_none() {
|
||||
return Err(CmdError::Params("give `gallery` and/or `hidden`".into()));
|
||||
}
|
||||
let st = s.doc.styles.get_mut(&id).ok_or_else(|| CmdError::Params(format!("no style `{id}`")))?;
|
||||
if let Some(h) = hidden {
|
||||
st.hidden = h;
|
||||
}
|
||||
if let Some(g) = gallery {
|
||||
st.quick = g;
|
||||
// Showing a style in the gallery unhides it.
|
||||
if g {
|
||||
st.hidden = false;
|
||||
}
|
||||
}
|
||||
let out = json!({"style": st.id, "inGallery": st.quick && !st.hidden && st.kind != StyleKind::Table, "hidden": st.hidden});
|
||||
touch_all(s);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Manage Styles' list: paragraph, character, linked and table styles (a linked pair once, as
|
||||
/// its paragraph half), sorted as recommended (priority, then name) or alphabetically.
|
||||
pub fn manage_list(s: &Session, alphabetical: bool) -> Value {
|
||||
let sheet = &s.doc.styles;
|
||||
let links_back = |a: &Style, b: &str| sheet.get(b).is_some_and(|x| x.linked.as_deref() == Some(a.id.as_str()));
|
||||
let mut list: Vec<&Style> = sheet
|
||||
.styles
|
||||
.iter()
|
||||
.filter(|st| match st.kind {
|
||||
StyleKind::Numbering => false,
|
||||
// The character half of a linked pair is listed as the linked style.
|
||||
StyleKind::Character => {
|
||||
!st.linked.as_deref().is_some_and(|l| sheet.get(l).is_some_and(|x| x.kind == StyleKind::Paragraph) && links_back(st, l))
|
||||
}
|
||||
_ => true,
|
||||
})
|
||||
.collect();
|
||||
if alphabetical {
|
||||
list.sort_by_cached_key(|st| st.name.to_lowercase());
|
||||
} else {
|
||||
list.sort_by_cached_key(|st| (st.priority.unwrap_or(99), st.name.to_lowercase()));
|
||||
}
|
||||
Value::Array(
|
||||
list.into_iter()
|
||||
.map(|st| {
|
||||
let ty = match st.kind {
|
||||
// Linked: its character half links back (or isn't in the document yet).
|
||||
StyleKind::Paragraph
|
||||
if st.linked.as_deref().is_some_and(|l| sheet.get(l).is_none_or(|x| x.kind == StyleKind::Character && links_back(st, l))) =>
|
||||
{
|
||||
"linked"
|
||||
}
|
||||
StyleKind::Paragraph => "paragraph",
|
||||
StyleKind::Character => "character",
|
||||
_ => "table",
|
||||
};
|
||||
json!({
|
||||
"id": st.id,
|
||||
"name": st.name,
|
||||
"type": ty,
|
||||
"builtIn": st.builtin || (st.kind == StyleKind::Table && super::table_style::is_builtin_table_style(st)),
|
||||
"inGallery": st.quick && !st.hidden && st.kind != StyleKind::Table,
|
||||
"hidden": st.hidden,
|
||||
"basedOn": st.based_on.as_deref().and_then(|b| sheet.get(b)).map(|b| b.name.clone()),
|
||||
"priority": st.priority,
|
||||
"format": own_format(st),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The formatting a style sets itself (not what it inherits), for its description.
|
||||
fn own_format(st: &Style) -> Value {
|
||||
let mut f = serde_json::Map::new();
|
||||
let c = &st.chr;
|
||||
if let Some(x) = &c.font {
|
||||
f.insert("font".into(), json!(x));
|
||||
}
|
||||
if let Some(x) = c.size {
|
||||
f.insert("size".into(), json!(x));
|
||||
}
|
||||
for (k, v) in [("bold", c.bold), ("italic", c.italic), ("caps", c.caps), ("smallCaps", c.small_caps)] {
|
||||
if let Some(b) = v {
|
||||
f.insert(k.into(), json!(b));
|
||||
}
|
||||
}
|
||||
if let Some(wordcraft_doc::TextColor::Rgb(rgb)) = c.color {
|
||||
f.insert("color".into(), json!(rgb.hex()));
|
||||
}
|
||||
let pp = &st.para;
|
||||
if let Some(a) = pp.align {
|
||||
f.insert("align".into(), json!(a));
|
||||
}
|
||||
if let Some(x) = pp.space_before {
|
||||
f.insert("spaceBefore".into(), json!(x));
|
||||
}
|
||||
if let Some(x) = pp.space_after {
|
||||
f.insert("spaceAfter".into(), json!(x));
|
||||
}
|
||||
if let Some(x) = pp.indent_left {
|
||||
f.insert("indentLeft".into(), json!(x));
|
||||
}
|
||||
if let Some(LineSpacing::Multiple(m)) = pp.line_spacing {
|
||||
f.insert("lineSpacing".into(), json!(m));
|
||||
}
|
||||
Value::Object(f)
|
||||
}
|
||||
|
||||
/// Style edits re-lay out every paragraph: the layout cache is keyed on the style sheet, so
|
||||
/// nothing needs touching here (kept as the one place to hook style-change side effects).
|
||||
pub fn touch_all(s: &mut Session) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,215 @@
|
|||
//! Paste Special: paste the clipboard in a chosen format (formatted, RTF, HTML, unformatted).
|
||||
//!
|
||||
//! The clipboard can hold several representations of the same content. WordCraft's own copy keeps
|
||||
//! a rich fragment; the system clipboard gives text, and callers (agents, the CLI, a front end
|
||||
//! with a richer clipboard) may pass `html` or `rtf` too. Text that is itself RTF or HTML source
|
||||
//! can also be pasted as that format.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::edit::Fragment;
|
||||
use wordcraft_doc::{Block, Document, Paragraph};
|
||||
|
||||
use super::{delete_selection, sel_result};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
/// Largest clipboard payload Paste Special accepts, in bytes.
|
||||
pub const MAX_PASTE_BYTES: usize = 16 << 20;
|
||||
/// Nesting depth we follow when adjusting pasted tables.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// The formats, in the order the dialog lists them: (id, label).
|
||||
pub const FORMATS: [(&str, &str); 4] =
|
||||
[("formatted", "Formatted Text"), ("rtf", "Rich Text Format (RTF)"), ("html", "HTML Format"), ("text", "Unformatted Text")];
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("edit.pasteSpecial", "Paste Special", "Home › Clipboard › Paste", paste_special)
|
||||
.key("Mod+Alt+V")
|
||||
.params(r#"{"as"?: "formatted"|"rtf"|"html"|"text", "text"?: string, "html"?: string, "rtf"?: string}"#),
|
||||
]
|
||||
}
|
||||
|
||||
/// Clipboard text from the params (system clipboard), else WordCraft's own copy.
|
||||
fn clip_text(s: &Session, v: &Value) -> Option<String> {
|
||||
match p::str(v, "text") {
|
||||
Some(t) => Some(t.replace("\r\n", "\n")),
|
||||
None => s.clipboard.as_ref().map(Fragment::plain_text),
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_rtf(t: &str) -> bool {
|
||||
t.trim_start().starts_with("{\\rtf")
|
||||
}
|
||||
|
||||
fn looks_like_html(t: &str) -> bool {
|
||||
let t = t.trim_start();
|
||||
t.starts_with('<') && (t.contains("</") || t.contains("/>"))
|
||||
}
|
||||
|
||||
/// The source for a format, if the clipboard has it.
|
||||
fn source(s: &Session, v: &Value, fmt: &str) -> Option<String> {
|
||||
let non_empty = |t: String| if t.is_empty() { None } else { Some(t) };
|
||||
match fmt {
|
||||
"formatted" => {
|
||||
let f = s.clipboard.as_ref()?;
|
||||
// System clipboard text that isn't our own copy means someone else copied since.
|
||||
match p::str(v, "text") {
|
||||
Some(t) if f.plain_text() != t.replace("\r\n", "\n") => None,
|
||||
_ => Some(String::new()),
|
||||
}
|
||||
}
|
||||
"rtf" => p::str(v, "rtf").map(str::to_string).or_else(|| clip_text(s, v).filter(|t| looks_like_rtf(t))).and_then(non_empty),
|
||||
"html" => p::str(v, "html").map(str::to_string).or_else(|| clip_text(s, v).filter(|t| looks_like_html(t))).and_then(non_empty),
|
||||
"text" => clip_text(s, v).and_then(non_empty),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats the clipboard can be pasted as, best first.
|
||||
pub fn available(s: &Session, v: &Value) -> Vec<&'static str> {
|
||||
FORMATS.iter().map(|(id, _)| *id).filter(|id| source(s, v, id).is_some()).collect()
|
||||
}
|
||||
|
||||
fn formats_json(ids: &[&str]) -> Value {
|
||||
json!(ids.iter().map(|id| json!({"id": id, "label": FORMATS.iter().find(|(f, _)| f == id).map(|(_, l)| *l).unwrap_or(*id)})).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
fn paste_special(s: &mut Session, v: &Value) -> CmdResult {
|
||||
for k in ["text", "html", "rtf"] {
|
||||
if p::str(v, k).is_some_and(|t| t.len() > MAX_PASTE_BYTES) {
|
||||
return Err(CmdError::Params(format!("`{k}` is larger than {} MB", MAX_PASTE_BYTES >> 20)));
|
||||
}
|
||||
}
|
||||
let ids = available(s, v);
|
||||
let Some(fmt) = p::str(v, "as") else {
|
||||
// No format chosen: list them and ask a front end (if any) to show the dialog.
|
||||
let mut req = json!({"open": "pasteSpecial", "formats": ids});
|
||||
for k in ["text", "html", "rtf"] {
|
||||
if let Some(t) = p::str(v, k) {
|
||||
req[k] = json!(t);
|
||||
}
|
||||
}
|
||||
s.ui_requests.push(req);
|
||||
return Ok(json!({"formats": formats_json(&ids)}));
|
||||
};
|
||||
let fmt = match fmt.to_ascii_lowercase().as_str() {
|
||||
"formatted" | "keep" | "wordcraft" => "formatted",
|
||||
"rtf" => "rtf",
|
||||
"html" => "html",
|
||||
"text" | "unformatted" | "plain" => "text",
|
||||
other => return Err(CmdError::Params(format!("unknown format `{other}` (formatted, rtf, html or text)"))),
|
||||
};
|
||||
let Some(src) = source(s, v, fmt) else {
|
||||
return Err(CmdError::Failed(if ids.is_empty() {
|
||||
"the clipboard is empty".into()
|
||||
} else {
|
||||
format!("the clipboard has no {fmt} content (available: {})", ids.join(", "))
|
||||
}));
|
||||
};
|
||||
let frag = match fmt {
|
||||
"text" => {
|
||||
super::type_text(s, &src.replace('\n', "\r"))?;
|
||||
let mut r = sel_result(s)?;
|
||||
r["pastedAs"] = json!(fmt);
|
||||
return Ok(r);
|
||||
}
|
||||
"formatted" => s.clipboard.clone().ok_or_else(|| CmdError::Failed("the clipboard is empty".into()))?,
|
||||
"html" => adopt(s, wordcraft_formats::html::import(&src)),
|
||||
_ => adopt(s, wordcraft_formats::rtf::import(src.as_bytes()).map_err(|e| CmdError::Failed(format!("bad RTF: {e}")))?),
|
||||
};
|
||||
insert(s, frag)?;
|
||||
let mut r = sel_result(s)?;
|
||||
r["pastedAs"] = json!(fmt);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Replace the selection with `frag`, marking it as an insertion under Track Changes.
|
||||
pub fn insert(s: &mut Session, mut frag: Fragment) -> Result<(), CmdError> {
|
||||
let at = delete_selection(s)?;
|
||||
if s.doc.settings.track_changes {
|
||||
let rid = super::new_revision(s, wordcraft_doc::RevisionKind::Insert);
|
||||
for b in &mut frag.blocks {
|
||||
each_para(b, 0, &mut |p| {
|
||||
for r in &mut p.runs {
|
||||
r.props.ins = Some(rid);
|
||||
r.props.del = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
let end = s.doc.insert_fragment(&at, &frag)?;
|
||||
s.sel = Selection::caret(end);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every paragraph in a block, tables included (bounded depth).
|
||||
fn each_para(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph)) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
match b {
|
||||
Block::Para(p) => f(p),
|
||||
Block::Table(t) => {
|
||||
for row in &mut t.rows {
|
||||
for cell in &mut row.cells {
|
||||
for cb in &mut cell.blocks {
|
||||
each_para(Arc::make_mut(cb), depth + 1, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn an imported document into a fragment of the session's document: bring over the media,
|
||||
/// lists and styles it refers to (renumbered so they can't collide with ours).
|
||||
fn adopt(s: &mut Session, src: Document) -> Fragment {
|
||||
let mut media = BTreeMap::new();
|
||||
for (key, data) in &src.media {
|
||||
let ext = key.rsplit_once('.').map(|(_, e)| e).unwrap_or("png");
|
||||
media.insert(key.clone(), s.doc.add_media(data.to_vec(), ext));
|
||||
}
|
||||
let next_abs = s.doc.numbering.abstracts.iter().map(|a| a.id.saturating_add(1)).max().unwrap_or(0);
|
||||
let mut abs_map = BTreeMap::new();
|
||||
for (i, a) in src.numbering.abstracts.iter().enumerate() {
|
||||
let id = next_abs.saturating_add(i as u32);
|
||||
abs_map.insert(a.id, id);
|
||||
s.doc.numbering.abstracts.push(wordcraft_doc::numbering::AbstractNum { id, ..a.clone() });
|
||||
}
|
||||
let next_num = s.doc.numbering.nums.iter().map(|n| n.id.saturating_add(1)).max().unwrap_or(1).max(1);
|
||||
let mut num_map = BTreeMap::new();
|
||||
for (i, n) in src.numbering.nums.iter().enumerate() {
|
||||
let id = next_num.saturating_add(i as u32);
|
||||
num_map.insert(n.id, id);
|
||||
let abstract_id = abs_map.get(&n.abstract_id).copied().unwrap_or(n.abstract_id);
|
||||
s.doc.numbering.nums.push(wordcraft_doc::numbering::Num { id, abstract_id, ..n.clone() });
|
||||
}
|
||||
for st in &src.styles.styles {
|
||||
if s.doc.styles.get(&st.id).is_none() {
|
||||
s.doc.styles.upsert(st.clone());
|
||||
}
|
||||
}
|
||||
let mut blocks: Vec<Block> = src.body.iter().map(|b| (**b).clone()).collect();
|
||||
for b in &mut blocks {
|
||||
each_para(b, 0, &mut |p| {
|
||||
if let Some(n) = p.props.numbering.as_mut()
|
||||
&& n.num != 0
|
||||
{
|
||||
n.num = num_map.get(&n.num).copied().unwrap_or(0);
|
||||
}
|
||||
for o in &mut p.objects {
|
||||
if let wordcraft_doc::InlineObject::Image { media: key, .. } = o
|
||||
&& let Some(k) = media.get(key.as_str())
|
||||
{
|
||||
*key = k.clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Text box stories the imported shapes show; pasting gives each a new story of its own.
|
||||
let parts = src.parts.into_iter().filter(|(_, p)| p.kind == wordcraft_doc::PartKind::TextBox).collect();
|
||||
Fragment { blocks, parts }
|
||||
}
|
||||
|
|
@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
t(CommandSpec::new("table.splitTable", "Split Table", "Table Layout › Merge", split_table)),
|
||||
t(CommandSpec::new("table.style", "Table Styles", "Table Design › Table Styles", |s, v| {
|
||||
let st = p::req_str(v, "style")?;
|
||||
let id = s.doc.styles.find(st).map(|x| x.id.clone()).ok_or_else(|| CmdError::Params(format!("no table style `{st}`")))?;
|
||||
let id = super::table_style::table_style_id(s, st).ok_or_else(|| CmdError::Params(format!("no table style `{st}`")))?;
|
||||
with_table(s, |t| t.props.style = Some(id.clone()))
|
||||
})
|
||||
.params(r#"{"style": string}"#)),
|
||||
|
|
@ -242,7 +242,6 @@ fn with_cells(s: &mut Session, f: impl Fn(&mut wordcraft_doc::Cell)) -> CmdResul
|
|||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Bump the revision of every paragraph in a table (table style changes alter their layout).
|
||||
/// Table Layout › Text Direction: turn the selected cells' text. Without a value it cycles the
|
||||
/// caret cell's direction (horizontal → down → up) and gives every selected cell the result.
|
||||
fn text_direction(s: &mut Session, v: &Value) -> CmdResult {
|
||||
|
|
@ -260,7 +259,8 @@ fn text_direction(s: &mut Session, v: &Value) -> CmdResult {
|
|||
with_cells(s, |cl| cl.props.text_direction = dir)
|
||||
}
|
||||
|
||||
fn touch_cells(s: &mut Session, tp: &Path) -> Result<(), CmdError> {
|
||||
/// Bump the revision of every paragraph in a table (table style changes alter their layout).
|
||||
pub(crate) fn touch_cells(s: &mut Session, tp: &Path) -> Result<(), CmdError> {
|
||||
let story = s.sel.focus.story;
|
||||
let paths: Vec<Path> = s.doc.para_paths(story).into_iter().filter(|p| p.0.len() > tp.0.len() && p.0.starts_with(&tp.0)).collect();
|
||||
for p in paths {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,336 @@
|
|||
//! Table Design › Table Styles: create, modify and delete table styles (whole table, header and
|
||||
//! total rows, first and last columns, banded rows and columns), stored as table styles in the
|
||||
//! document's style sheet (ECMA-376 §17.7.6).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, ParaProps, Rgb, TextColor};
|
||||
use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts, TableStyleProps};
|
||||
use wordcraft_doc::{Block, Table};
|
||||
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
/// The regions a table style formats, by param name: the table style options they follow.
|
||||
pub const REGIONS: [&str; 7] = ["wholeTable", "headerRow", "lastRow", "firstColumn", "lastColumn", "bandedRows", "bandedColumns"];
|
||||
|
||||
/// The formatting params both commands share; each region (`wholeTable`, `headerRow`, `lastRow`
|
||||
/// (the total row), `firstColumn`, `lastColumn`, `bandedRows`, `bandedColumns`) takes the same
|
||||
/// shortcuts.
|
||||
macro_rules! style_params {
|
||||
($head:literal) => {
|
||||
concat!(
|
||||
"{",
|
||||
$head,
|
||||
r#", "basedOn"?: string, "wholeTable"?: Region, "headerRow"?: Region, "lastRow"?: Region (total row), "firstColumn"?: Region, "lastColumn"?: Region, "bandedRows"?: Region, "bandedColumns"?: Region, "bandSize"?: n (rows per band), "para"?: ParaProps, "apply"?: bool}"#,
|
||||
r#" where Region = {"borders"?: bool | null (true: single lines, false: none, null: inherit), "borderColor"?: "RRGGBB", "borderWidth"?: pt, "fill"?: "RRGGBB" | null, "bold"?: bool, "italic"?: bool, "color"?: "RRGGBB" | null, "size"?: pt, "font"?: string}"#
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("table.newStyle", "New Table Style", "Table Design › Table Styles", new_style).params(style_params!(r#""name": string"#)),
|
||||
CommandSpec::new("table.modifyStyle", "Modify Table Style", "Table Design › Table Styles", modify_style)
|
||||
.params(style_params!(r#""style"?: string (default: the current table's), "name"?: string"#)),
|
||||
CommandSpec::new("table.deleteStyle", "Delete Table Style", "Table Design › Table Styles", delete_style)
|
||||
.params(r#"{"style"?: string (default: the current table's)}"#),
|
||||
]
|
||||
}
|
||||
|
||||
/// A table style's id by id or display name (case-insensitive); styles of other kinds don't count.
|
||||
pub fn table_style_id(s: &Session, name: &str) -> Option<String> {
|
||||
let st = &s.doc.styles.styles;
|
||||
st.iter()
|
||||
.find(|x| x.kind == StyleKind::Table && x.id == name)
|
||||
.or_else(|| st.iter().find(|x| x.kind == StyleKind::Table && x.name.eq_ignore_ascii_case(name)))
|
||||
.map(|x| x.id.clone())
|
||||
}
|
||||
|
||||
/// The style of the table at the caret.
|
||||
fn current_table_style(s: &Session) -> Option<String> {
|
||||
let (tp, _, _) = s.sel.focus.path.cell()?;
|
||||
s.doc.table(s.sel.focus.story, &tp)?.props.style.clone()
|
||||
}
|
||||
|
||||
/// The style `table.newStyle` would add for `v`, validated.
|
||||
fn build_new(s: &Session, v: &Value) -> Result<Style, CmdError> {
|
||||
let name = p::req_str(v, "name")?.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err(CmdError::Params("name is empty".into()));
|
||||
}
|
||||
if s.doc.styles.find(&name).is_some() {
|
||||
return Err(CmdError::Failed(format!("a style named `{name}` already exists")));
|
||||
}
|
||||
let based_on = match p::str(v, "basedOn") {
|
||||
Some(b) => Some(table_style_id(s, b).ok_or_else(|| CmdError::Params(format!("no table style `{b}`")))?),
|
||||
None => table_style_id(s, "TableGrid").or_else(|| table_style_id(s, "TableNormal")),
|
||||
};
|
||||
let id = s.doc.styles.new_id(&name);
|
||||
let mut st = Style { id, name, kind: StyleKind::Table, based_on, priority: Some(99), ..Default::default() };
|
||||
edit(&mut st, v)?;
|
||||
Ok(st)
|
||||
}
|
||||
|
||||
fn new_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let st = build_new(s, v)?;
|
||||
let id = st.id.clone();
|
||||
s.doc.styles.upsert(st);
|
||||
s.dirty = true;
|
||||
if p::bool(v, "apply").unwrap_or(true) {
|
||||
apply(s, &id)?;
|
||||
}
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
|
||||
/// The table style `v` names (`style`), else the current table's.
|
||||
fn target(s: &Session, v: &Value) -> Result<String, CmdError> {
|
||||
match p::str(v, "style") {
|
||||
Some(n) => table_style_id(s, n).ok_or_else(|| CmdError::Params(format!("no table style `{n}`"))),
|
||||
None => current_table_style(s).ok_or_else(|| CmdError::Disabled("the cursor isn't in a table with a style".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn modify_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let st = build_modified(s, v)?;
|
||||
let id = st.id.clone();
|
||||
s.doc.styles.upsert(st);
|
||||
s.dirty = true;
|
||||
if p::bool(v, "apply").unwrap_or(false) {
|
||||
apply(s, &id)?;
|
||||
}
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
|
||||
/// The style `table.modifyStyle` would store for `v`, validated.
|
||||
fn build_modified(s: &Session, v: &Value) -> Result<Style, CmdError> {
|
||||
let id = target(s, v)?;
|
||||
let rename = p::str(v, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
if let Some(n) = &rename
|
||||
&& s.doc.styles.find(n).is_some_and(|x| x.id != id)
|
||||
{
|
||||
return Err(CmdError::Failed(format!("a style named `{n}` already exists")));
|
||||
}
|
||||
let based_on = match p::str(v, "basedOn") {
|
||||
Some(b) => {
|
||||
let b = table_style_id(s, b).ok_or_else(|| CmdError::Params(format!("no table style `{b}`")))?;
|
||||
// A style can't be based on itself or on a style based on it.
|
||||
if s.doc.styles.chain(&b).iter().any(|x| x.id == id) {
|
||||
return Err(CmdError::Params("a style can't be based on itself".into()));
|
||||
}
|
||||
Some(b)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let mut st = s.doc.styles.get(&id).cloned().ok_or_else(|| CmdError::Params("no such style".into()))?;
|
||||
if let Some(n) = rename {
|
||||
st.name = n;
|
||||
}
|
||||
if let Some(b) = based_on {
|
||||
st.based_on = Some(b);
|
||||
}
|
||||
edit(&mut st, v)?;
|
||||
Ok(st)
|
||||
}
|
||||
|
||||
/// How table style `v["style"]` (or, without it, a new style) would look with the formatting
|
||||
/// params of `table.modifyStyle` / `table.newStyle` applied, for a dialog's live preview. Names
|
||||
/// aren't checked; `None` when the params don't validate.
|
||||
pub fn preview(s: &Session, v: &Value) -> Option<TableStyleProps> {
|
||||
if !v.is_object() {
|
||||
return None;
|
||||
}
|
||||
let mut v = v.clone();
|
||||
let st = if p::str(&v, "style").is_some() {
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.remove("name");
|
||||
}
|
||||
build_modified(s, &v).ok()?
|
||||
} else {
|
||||
// A name no style has: the look doesn't depend on it.
|
||||
v["name"] = json!("\u{1}preview");
|
||||
build_new(s, &v).ok()?
|
||||
};
|
||||
let id = st.id.clone();
|
||||
let mut sheet = s.doc.styles.clone();
|
||||
sheet.upsert(st);
|
||||
sheet.table_style(&id)
|
||||
}
|
||||
|
||||
/// Delete a custom table style. Styles based on it are re-based on its own base with its
|
||||
/// formatting folded in, so they look the same; tables using it take Table Grid (the style new
|
||||
/// tables get), or no style when the document has no Table Grid.
|
||||
fn delete_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let id = target(s, v)?;
|
||||
delete(s, &id)
|
||||
}
|
||||
|
||||
/// Delete table style `id` (also what `styles.delete` runs for a table style): see
|
||||
/// [`delete_style`].
|
||||
pub fn delete(s: &mut Session, id: &str) -> CmdResult {
|
||||
let id = id.to_string();
|
||||
let st = s.doc.styles.get(&id).cloned().ok_or_else(|| CmdError::Params("no such style".into()))?;
|
||||
if st.kind != StyleKind::Table {
|
||||
return Err(CmdError::Params(format!("`{}` isn't a table style", st.name)));
|
||||
}
|
||||
if is_builtin_table_style(&st) {
|
||||
return Err(CmdError::Failed(format!("`{}` is a built-in table style and can't be deleted", st.name)));
|
||||
}
|
||||
let mut rebased = Vec::new();
|
||||
for child in s.doc.styles.styles.iter_mut().filter(|c| c.based_on.as_deref() == Some(id.as_str())) {
|
||||
let mut para = st.para.clone();
|
||||
para.overlay(&child.para);
|
||||
child.para = para;
|
||||
let mut chr = st.chr.clone();
|
||||
chr.overlay(&child.chr);
|
||||
child.chr = chr;
|
||||
if child.kind == StyleKind::Table {
|
||||
let mut parts = st.table.clone().unwrap_or_default();
|
||||
if let Some(t) = &child.table {
|
||||
parts.overlay(t);
|
||||
}
|
||||
child.table = (parts != TableStyleParts::default()).then_some(parts);
|
||||
}
|
||||
child.based_on = st.based_on.clone();
|
||||
rebased.push(child.id.clone());
|
||||
}
|
||||
s.doc.styles.styles.retain(|x| x.id != id);
|
||||
let fallback = table_style_id(s, "TableGrid");
|
||||
let mut tables = restyle(&mut s.doc.body, &id, fallback.as_deref(), 0);
|
||||
for part in s.doc.parts.values_mut() {
|
||||
tables += restyle(&mut part.blocks, &id, fallback.as_deref(), 0);
|
||||
}
|
||||
s.dirty = true;
|
||||
Ok(json!({"deleted": id, "tables": tables, "rebased": rebased}))
|
||||
}
|
||||
|
||||
/// Whether `st` is one of Word's built-in table styles. A .docx can mark any style custom
|
||||
/// (`w:customStyle`), so Table Grid and Normal Table are recognised by id and name too: deleting
|
||||
/// them would leave new tables (and the fallback for deleted styles) without a style to take.
|
||||
pub fn is_builtin_table_style(st: &Style) -> bool {
|
||||
const IDS: [&str; 2] = ["TableGrid", "TableNormal"];
|
||||
const NAMES: [&str; 3] = ["Table Grid", "Normal Table", "Table Normal"];
|
||||
st.builtin || IDS.iter().any(|x| st.id.eq_ignore_ascii_case(x)) || NAMES.iter().any(|x| st.name.eq_ignore_ascii_case(x))
|
||||
}
|
||||
|
||||
/// Tables nest at most this deep for restyling.
|
||||
const MAX_DEPTH: usize = 32;
|
||||
|
||||
/// Whether table `t`, or one nested in it, uses style `id`.
|
||||
fn uses_style(t: &Table, id: &str, depth: usize) -> bool {
|
||||
t.props.style.as_deref() == Some(id)
|
||||
|| (depth < MAX_DEPTH
|
||||
&& t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks).any(|b| b.as_table().is_some_and(|t| uses_style(t, id, depth + 1))))
|
||||
}
|
||||
|
||||
/// Give every table in `blocks` that uses style `from` style `to`; the number restyled.
|
||||
fn restyle(blocks: &mut [Arc<Block>], from: &str, to: Option<&str>, depth: usize) -> usize {
|
||||
let mut n = 0;
|
||||
for b in blocks {
|
||||
if !b.as_table().is_some_and(|t| uses_style(t, from, depth)) {
|
||||
continue;
|
||||
}
|
||||
if let Block::Table(t) = Arc::make_mut(b) {
|
||||
if t.props.style.as_deref() == Some(from) {
|
||||
t.props.style = to.map(str::to_string);
|
||||
n += 1;
|
||||
}
|
||||
if depth < MAX_DEPTH {
|
||||
for c in t.rows.iter_mut().flat_map(|r| &mut r.cells) {
|
||||
n += restyle(&mut c.blocks, from, to, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Apply style `id` to the table at the caret, if any.
|
||||
fn apply(s: &mut Session, id: &str) -> Result<(), CmdError> {
|
||||
let Some((tp, _, _)) = s.sel.focus.path.cell() else { return Ok(()) };
|
||||
s.doc.table_mut(s.sel.focus.story, &tp)?.props.style = Some(id.to_string());
|
||||
super::table::touch_cells(s, &tp)
|
||||
}
|
||||
|
||||
/// Apply the formatting params to `st`.
|
||||
fn edit(st: &mut Style, v: &Value) -> Result<(), CmdError> {
|
||||
let mut parts = st.table.take().unwrap_or_default();
|
||||
if let Some(r) = v.get("wholeTable") {
|
||||
region(r, &mut parts.fill, &mut st.chr, &mut parts.borders)?;
|
||||
}
|
||||
if let Some(r) = v.get("headerRow") {
|
||||
region(r, &mut parts.header_fill, &mut parts.header_chr, &mut parts.header_borders)?;
|
||||
}
|
||||
if let Some(r) = v.get("bandedRows") {
|
||||
region(r, &mut parts.band_fill, &mut parts.band_chr, &mut parts.band_borders)?;
|
||||
}
|
||||
if let Some(r) = v.get("lastRow") {
|
||||
region(r, &mut parts.total_fill, &mut parts.total_chr, &mut parts.total_borders)?;
|
||||
}
|
||||
if let Some(r) = v.get("firstColumn") {
|
||||
region(r, &mut parts.first_col_fill, &mut parts.first_col_chr, &mut parts.first_col_borders)?;
|
||||
}
|
||||
if let Some(r) = v.get("lastColumn") {
|
||||
region(r, &mut parts.last_col_fill, &mut parts.last_col_chr, &mut parts.last_col_borders)?;
|
||||
}
|
||||
if let Some(r) = v.get("bandedColumns") {
|
||||
region(r, &mut parts.col_band_fill, &mut parts.col_band_chr, &mut parts.col_band_borders)?;
|
||||
}
|
||||
if let Some(n) = v.get("bandSize") {
|
||||
parts.band_size = n.as_u64().map(|n| n.clamp(1, 1000) as u32);
|
||||
}
|
||||
if let Some(pp) = v.get("para") {
|
||||
let pp: ParaProps = serde_json::from_value(pp.clone()).map_err(|e| CmdError::Params(format!("para: {e}")))?;
|
||||
st.para.overlay(&pp);
|
||||
st.para.style = None;
|
||||
st.para.numbering = None;
|
||||
}
|
||||
st.table = (parts != TableStyleParts::default()).then_some(parts);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One region's shortcuts: borders, fill and character formatting.
|
||||
fn region(r: &Value, fill: &mut Option<Rgb>, chr: &mut CharProps, borders: &mut Option<Borders>) -> Result<(), CmdError> {
|
||||
if !r.is_object() {
|
||||
return Err(CmdError::Params("a region is an object".into()));
|
||||
}
|
||||
let color = |k: &str| -> Result<Option<Rgb>, CmdError> {
|
||||
match r.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(h)) => Rgb::parse(h).map(Some).ok_or_else(|| CmdError::Params(format!("`{k}`: bad colour `{h}`"))),
|
||||
Some(_) => Err(CmdError::Params(format!("`{k}` is \"RRGGBB\" or null"))),
|
||||
}
|
||||
};
|
||||
if r.get("fill").is_some() {
|
||||
*fill = color("fill")?;
|
||||
}
|
||||
if r.get("color").is_some() {
|
||||
chr.color = color("color")?.map(TextColor::Rgb);
|
||||
}
|
||||
if let Some(b) = p::bool(r, "bold") {
|
||||
chr.bold = Some(b);
|
||||
chr.bold_cs = Some(b);
|
||||
}
|
||||
if let Some(b) = p::bool(r, "italic") {
|
||||
chr.italic = Some(b);
|
||||
chr.italic_cs = Some(b);
|
||||
}
|
||||
if let Some(sz) = p::f32(r, "size") {
|
||||
chr.size = Some(sz.clamp(1.0, 1638.0));
|
||||
chr.size_cs = chr.size;
|
||||
}
|
||||
if let Some(f) = p::str(r, "font").map(str::trim).filter(|f| !f.is_empty()) {
|
||||
chr.font = Some(f.to_string());
|
||||
}
|
||||
match r.get("borders") {
|
||||
Some(Value::Bool(true)) => {
|
||||
let width = p::f32(r, "borderWidth").unwrap_or(0.5).clamp(0.25, 6.0);
|
||||
*borders = Some(Borders::all(Border { style: BorderStyle::Single, width, color: color("borderColor")?, space: 0.0 }));
|
||||
}
|
||||
Some(Value::Bool(false)) => *borders = Some(Borders::all(Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 })),
|
||||
Some(Value::Null) => *borders = None,
|
||||
Some(_) => return Err(CmdError::Params("`borders` is true, false or null".into())),
|
||||
None => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -33,18 +33,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
CommandSpec::new("view.readMode", "Read Mode", "View › Views", |s, _| mode(s, ViewMode::Print, true)).pure(),
|
||||
CommandSpec::new("view.focus", "Focus", "View › Immersive", |s, v| toggle(s, v, |x| &mut x.focus_mode)).pure(),
|
||||
CommandSpec::new("view.ruler", "Ruler", "View › Show", |s, v| toggle(s, v, |x| &mut x.ruler)).pure(),
|
||||
CommandSpec::new("view.gridlines", "Gridlines", "View › Show", |s, v| toggle(s, v, |x| &mut x.gridlines)).pure(),
|
||||
// The drawing grid over the page (screen only; never printed or exported).
|
||||
CommandSpec::new("view.gridlines", "Gridlines", "View › Show", |s, v| toggle(s, v, |x| &mut x.gridlines))
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
.pure(),
|
||||
// Outlines of table cells (screen only; never printed or exported).
|
||||
CommandSpec::new("table.viewGridlines", "View Gridlines", "Table Layout › Table", |s, v| toggle(s, v, |x| &mut x.table_gridlines))
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.navigationPane", "Navigation Pane", "View › Show", |s, v| toggle(s, v, |x| &mut x.nav_pane)).pure(),
|
||||
CommandSpec::new("view.zoom", "Zoom", "View › Zoom", zoom)
|
||||
.params(r#"{"value": percent (10-500) | "pageWidth" | "onePage" | "multiplePages"}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.zoom100", "100%", "View › Zoom", |s, _| zoom(s, &json!({"value": 100}))).pure(),
|
||||
CommandSpec::new("view.zoomIn", "Zoom In", "Status Bar", |s, _| {
|
||||
CommandSpec::new("view.zoomIn", "Zoom In", "View › Zoom", |s, _| {
|
||||
let z = ((s.view.zoom * 10.0).round() / 10.0 + 0.1).min(5.0);
|
||||
zoom(s, &json!({"value": z * 100.0}))
|
||||
})
|
||||
.pure(),
|
||||
CommandSpec::new("view.zoomOut", "Zoom Out", "Status Bar", |s, _| {
|
||||
CommandSpec::new("view.zoomOut", "Zoom Out", "View › Zoom", |s, _| {
|
||||
let z = ((s.view.zoom * 10.0).round() / 10.0 - 0.1).max(0.1);
|
||||
zoom(s, &json!({"value": z * 100.0}))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ use std::collections::HashMap;
|
|||
|
||||
use serde_json::Value;
|
||||
|
||||
pub use session::{EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, ViewState};
|
||||
pub use session::{ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, ViewState};
|
||||
pub use wordcraft_doc as doc;
|
||||
pub use wordcraft_layout as layout;
|
||||
pub use wordcraft_render as render;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,19 @@ impl Selection {
|
|||
}
|
||||
}
|
||||
|
||||
/// A column (block) selection: the same x range on every line between two corners.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ColumnBlock {
|
||||
/// The stream selection the block was made with (its corners). The block is current only
|
||||
/// while `Session::sel` still equals it, so any ordinary caret move or selection drops it.
|
||||
pub sel: Selection,
|
||||
/// Page x of the anchor and focus corners (points); the block spans the range between.
|
||||
pub anchor_x: f32,
|
||||
pub focus_x: f32,
|
||||
/// One `(start, end)` per line, in document order, each inside one paragraph.
|
||||
pub segments: Vec<(Pos, Pos)>,
|
||||
}
|
||||
|
||||
/// View state that commands can change (ribbon View tab, status bar).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
@ -41,10 +54,20 @@ pub struct ViewState {
|
|||
pub focus_mode: bool,
|
||||
pub marks: bool,
|
||||
pub ruler: bool,
|
||||
/// View › Show › Gridlines: the drawing grid over the page's text area (on screen only).
|
||||
pub gridlines: bool,
|
||||
/// Table Layout › View Gridlines: outlines of table cells (on screen only).
|
||||
#[serde(default)]
|
||||
pub table_gridlines: bool,
|
||||
pub nav_pane: bool,
|
||||
pub styles_pane: bool,
|
||||
/// Style Inspector pane (Home › Styles).
|
||||
#[serde(default)]
|
||||
pub style_inspector: bool,
|
||||
pub comments_pane: bool,
|
||||
/// The Clipboard pane (Home › Clipboard): items collected by Copy and Cut.
|
||||
#[serde(default)]
|
||||
pub clipboard_pane: bool,
|
||||
pub multi_page: bool,
|
||||
/// Zoom to fit: "pageWidth", "onePage", "multiplePages", or empty.
|
||||
pub fit: String,
|
||||
|
|
@ -66,9 +89,12 @@ impl Default for ViewState {
|
|||
marks: false,
|
||||
ruler: true,
|
||||
gridlines: false,
|
||||
table_gridlines: false,
|
||||
nav_pane: false,
|
||||
styles_pane: false,
|
||||
style_inspector: false,
|
||||
comments_pane: false,
|
||||
clipboard_pane: false,
|
||||
multi_page: false,
|
||||
fit: String::new(),
|
||||
web_width: 800.0,
|
||||
|
|
@ -93,6 +119,11 @@ pub struct FindState {
|
|||
#[serde(skip)]
|
||||
pub results: Vec<(Pos, Pos)>,
|
||||
pub current: usize,
|
||||
/// Reading Highlight: mark every match of `query` in the body.
|
||||
pub highlight: bool,
|
||||
/// Highlighted matches, for the document revision they were found in.
|
||||
#[serde(skip)]
|
||||
pub highlights: Option<(u64, Vec<(Pos, Pos)>)>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
|
@ -152,6 +183,8 @@ pub struct Session {
|
|||
pub clipboard: Option<Fragment>,
|
||||
/// Plain text mirror of the clipboard (for the system clipboard).
|
||||
pub clipboard_text: String,
|
||||
/// Items collected by Copy and Cut this session, for the Clipboard pane.
|
||||
pub clip_history: crate::cmd::edit::ClipHistory,
|
||||
pub find: FindState,
|
||||
/// Page x the caret tries to keep on Up/Down.
|
||||
pub goal_x: Option<f32>,
|
||||
|
|
@ -210,6 +243,13 @@ pub struct Session {
|
|||
pub math_latex: bool,
|
||||
/// Text typed into equations is normal (non-math) text.
|
||||
pub math_normal_text: bool,
|
||||
/// More pictures and shapes selected along with the one the selection holds (Shift+click),
|
||||
/// for Group. Cleared by any edit or selection change other than adding to it.
|
||||
pub also_selected: Vec<Pos>,
|
||||
/// Column (block) selection, if one was made (see [`Session::column_segments`]).
|
||||
pub column: Option<ColumnBlock>,
|
||||
/// Column selection mode (Ctrl+Shift+F8): caret movement extends the block.
|
||||
pub column_mode: bool,
|
||||
}
|
||||
|
||||
/// The equation being edited.
|
||||
|
|
@ -250,6 +290,7 @@ impl Session {
|
|||
dirty: false,
|
||||
clipboard: None,
|
||||
clipboard_text: String::new(),
|
||||
clip_history: Default::default(),
|
||||
find: FindState::default(),
|
||||
goal_x: None,
|
||||
page_hint: 0,
|
||||
|
|
@ -281,11 +322,20 @@ impl Session {
|
|||
math: None,
|
||||
math_latex: false,
|
||||
math_normal_text: false,
|
||||
also_selected: Vec::new(),
|
||||
prefs: Prefs::default(),
|
||||
read_aloud: Default::default(),
|
||||
column: None,
|
||||
column_mode: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The line pieces of the current column selection, if there is one (the selection hasn't
|
||||
/// moved since the block was made).
|
||||
pub fn column_segments(&self) -> Option<&[(Pos, Pos)]> {
|
||||
self.column.as_ref().filter(|c| c.sel == self.sel).map(|c| c.segments.as_slice())
|
||||
}
|
||||
|
||||
/// Document revision (bumped by every change).
|
||||
pub fn rev(&self) -> u64 {
|
||||
self.rev
|
||||
|
|
@ -299,6 +349,18 @@ impl Session {
|
|||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Reading Highlight ranges for the current document (searched again after edits); empty when it's off.
|
||||
pub fn find_highlights(&mut self) -> &[(Pos, Pos)] {
|
||||
if !self.find.highlight {
|
||||
return &[];
|
||||
}
|
||||
if self.find.highlights.as_ref().is_none_or(|(r, _)| *r != self.rev) {
|
||||
let found = crate::cmd::edit::search(self, StoryRef::Body).unwrap_or_default();
|
||||
self.find.highlights = Some((self.rev, found));
|
||||
}
|
||||
self.find.highlights.as_ref().map(|(_, v)| v.as_slice()).unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// The current layout (recomputed when the document or view changed).
|
||||
pub fn layout(&mut self) -> Arc<DocLayout> {
|
||||
let ww = self.view.web_width;
|
||||
|
|
@ -362,6 +424,18 @@ impl Session {
|
|||
pub fn join_next_undo(&mut self) {
|
||||
self.join_next = true;
|
||||
}
|
||||
/// Stamp the document's save metadata (modified time, last modified by, revision; created
|
||||
/// when it has none) the way Word does on every save. `file.save` calls it, and so does any
|
||||
/// front end that writes the bytes itself (the web build's download, #262).
|
||||
pub fn stamp_save(&mut self) {
|
||||
let core = &mut self.doc.core;
|
||||
core.modified = crate::cmd::now_iso();
|
||||
if core.created.is_empty() {
|
||||
core.created = core.modified.clone();
|
||||
}
|
||||
core.last_modified_by = self.author.clone();
|
||||
core.revision = core.revision.saturating_add(1);
|
||||
}
|
||||
/// Close an open typing group (caret moved, other command).
|
||||
pub fn close_typing(&mut self) {
|
||||
self.typing_open = false;
|
||||
|
|
@ -415,6 +489,9 @@ impl Session {
|
|||
self.doc.ensure_nonempty();
|
||||
self.sel = Selection::caret(self.doc.start_of(StoryRef::Body));
|
||||
self.pending = None;
|
||||
self.also_selected.clear();
|
||||
self.column = None;
|
||||
self.column_mode = false;
|
||||
self.reset_history();
|
||||
self.touch();
|
||||
self.dirty = false;
|
||||
|
|
@ -447,6 +524,7 @@ impl Session {
|
|||
dirty,
|
||||
clipboard: _,
|
||||
clipboard_text: _,
|
||||
clip_history: _,
|
||||
find: _,
|
||||
goal_x: _,
|
||||
page_hint: _,
|
||||
|
|
@ -481,6 +559,10 @@ impl Session {
|
|||
math: _,
|
||||
math_latex: _,
|
||||
math_normal_text: _,
|
||||
also_selected: _,
|
||||
// Column selection, like `sel`'s shape: valid only while `sel` matches it.
|
||||
column: _,
|
||||
column_mode: _,
|
||||
} = self;
|
||||
let head = if history.len() + SNAPSHOT_HEAD > MAX_UNDO { history.iter().take(SNAPSHOT_HEAD).cloned().collect() } else { Vec::new() };
|
||||
EditSnapshot {
|
||||
|
|
@ -509,6 +591,7 @@ impl Session {
|
|||
self.undo_evicted = evicted;
|
||||
self.doc = doc;
|
||||
self.sel = sel;
|
||||
self.also_selected.clear();
|
||||
self.history.truncate(history_len);
|
||||
self.redo = redo;
|
||||
self.typing_open = typing_open;
|
||||
|
|
@ -581,7 +664,15 @@ impl Session {
|
|||
// The undo stacks are snapshotted whole, not as a length: commands run nested commands,
|
||||
// whose checkpoints, undos and redos (and evictions at the limit) must be put back too.
|
||||
let before_doc = if spec.mutates {
|
||||
Some((self.doc.clone(), self.sel.clone(), self.history.clone(), self.redo.clone(), self.typing_open, self.undo_evicted))
|
||||
Some((
|
||||
self.doc.clone(),
|
||||
self.sel.clone(),
|
||||
self.history.clone(),
|
||||
self.redo.clone(),
|
||||
self.typing_open,
|
||||
self.undo_evicted,
|
||||
self.column.clone(),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
|
@ -599,8 +690,10 @@ impl Session {
|
|||
} else if spec.id.starts_with("caret.") {
|
||||
self.typing_open = false;
|
||||
}
|
||||
let sel_before = self.sel.clone();
|
||||
let run = spec.run;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(self, params)));
|
||||
let mutates = spec.mutates;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::cmd::column::dispatch(self, id, mutates, run, params)));
|
||||
let result = match result {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
|
|
@ -616,9 +709,13 @@ impl Session {
|
|||
self.doc.prune_text_boxes();
|
||||
}
|
||||
self.clamp_selection();
|
||||
// Extra selected objects last until something else is edited or selected.
|
||||
if !matches!(id, "select.addObject" | "arrange.ungroup") && (spec.mutates || self.sel != sel_before) {
|
||||
self.also_selected.clear();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some((d, s, h, r, t, ev)) = before_doc {
|
||||
if let Some((d, s, h, r, t, ev, c)) = before_doc {
|
||||
self.doc = d;
|
||||
self.sel = s;
|
||||
self.history = h;
|
||||
|
|
@ -626,6 +723,7 @@ impl Session {
|
|||
self.typing_open = t;
|
||||
// The steps the command pushed out are back, so they no longer count as gone.
|
||||
self.undo_evicted = ev;
|
||||
self.column = c;
|
||||
}
|
||||
self.status = e.to_string();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -523,6 +523,151 @@ fn clipboard_round_trip() {
|
|||
assert_eq!(text(&s), "plain\ntext");
|
||||
}
|
||||
|
||||
fn bold_at(s: &Session, block: usize, off: usize) -> bool {
|
||||
let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(block), off };
|
||||
s.doc.para_at(&pos).and_then(|p| p.props_of_char(off).bold).unwrap_or(false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_special_lists_formats_and_pastes_text_without_formatting() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Bold words"}));
|
||||
run(&mut s, "select.text", json!({"text": "Bold"}));
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
// No format: the formats are listed (and a front end is asked to show the dialog).
|
||||
let r = run(&mut s, "edit.pasteSpecial", json!({}));
|
||||
let ids: Vec<&str> = r["formats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(ids, ["formatted", "text"]);
|
||||
assert_eq!(s.ui_requests.last().unwrap()["open"], "pasteSpecial");
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
let r = run(&mut s, "edit.pasteSpecial", json!({"as": "text"}));
|
||||
assert_eq!(r["pastedAs"], "text");
|
||||
assert_eq!(text(&s), "Bold wordsBold");
|
||||
assert!(bold_at(&s, 0, 0));
|
||||
assert!(!bold_at(&s, 0, 11), "unformatted text drops the bold");
|
||||
// Formatted keeps it.
|
||||
run(&mut s, "edit.pasteSpecial", json!({"as": "formatted"}));
|
||||
assert_eq!(text(&s), "Bold wordsBoldBold");
|
||||
assert!(bold_at(&s, 0, 15));
|
||||
// Text that someone else copied since: our rich copy is no longer offered.
|
||||
let r = run(&mut s, "edit.pasteSpecial", json!({"text": "elsewhere"}));
|
||||
assert_eq!(r["formats"].as_array().unwrap().len(), 1);
|
||||
assert!(s.run("edit.pasteSpecial", &json!({"as": "formatted", "text": "elsewhere"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_special_html_and_rtf_keep_bold() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "x"}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
let html = "<p>plain <b>strong</b></p>";
|
||||
let r = run(&mut s, "edit.pasteSpecial", json!({"html": html, "text": "plain strong"}));
|
||||
let ids: Vec<&str> = r["formats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(ids, ["html", "text"]);
|
||||
run(&mut s, "edit.pasteSpecial", json!({"as": "html", "html": html}));
|
||||
assert_eq!(text(&s), "xplain strong");
|
||||
assert!(!bold_at(&s, 0, 2));
|
||||
assert!(bold_at(&s, 0, 8), "the <b> run stays bold");
|
||||
// HTML source copied as text can be pasted as HTML too.
|
||||
run(&mut s, "document.setText", json!({"text": ""}));
|
||||
run(&mut s, "edit.pasteSpecial", json!({"as": "html", "text": "<b>hi</b> there</p>"}));
|
||||
assert_eq!(text(&s), "hi there");
|
||||
assert!(bold_at(&s, 0, 0) && !bold_at(&s, 0, 4));
|
||||
// RTF.
|
||||
run(&mut s, "document.setText", json!({"text": ""}));
|
||||
run(&mut s, "edit.pasteSpecial", json!({"as": "rtf", "rtf": r"{\rtf1\ansi {\b bold}\b0 then}"}));
|
||||
assert!(text(&s).starts_with("bold"), "{}", text(&s));
|
||||
assert!(bold_at(&s, 0, 0));
|
||||
// Asking for a format the clipboard lacks is an error, not a panic.
|
||||
assert!(s.run("edit.pasteSpecial", &json!({"as": "rtf", "text": "plain"})).is_err());
|
||||
assert!(s.run("edit.pasteSpecial", &json!({"as": "bogus", "text": "plain"})).is_err());
|
||||
let huge = "a".repeat(crate::cmd::paste::MAX_PASTE_BYTES + 1);
|
||||
assert!(s.run("edit.pasteSpecial", &json!({"as": "text", "text": huge})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_special_respects_track_changes_and_brings_lists() {
|
||||
let mut s = s();
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
run(&mut s, "edit.pasteSpecial", json!({"as": "html", "html": "<ul><li>one</li><li>two</li></ul>"}));
|
||||
assert!(text(&s).starts_with("one\ntwo"), "{}", text(&s));
|
||||
let ch = run(&mut s, "review.changes", json!({}));
|
||||
assert!(!ch.as_array().unwrap().is_empty(), "the paste is a tracked insertion");
|
||||
let first = s.doc.body.first().and_then(|b| b.as_para()).unwrap();
|
||||
let n = first.props.numbering.expect("list kept");
|
||||
assert!(s.doc.numbering.nums.iter().any(|x| x.id == n.num), "the list definition came along");
|
||||
run(&mut s, "review.acceptAll", json!({}));
|
||||
assert!(text(&s).starts_with("one\ntwo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_pane_collects_copies_and_pastes_one_undoably() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "one two three"}));
|
||||
for (a, b) in [(0, 3), (4, 7), (8, 13)] {
|
||||
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": a}, "focus": {"block": 0, "off": b}}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
}
|
||||
// Copying the same text again adds nothing.
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
let items = run(&mut s, "edit.clipboardItems", json!({}));
|
||||
let previews: Vec<&str> = items.as_array().unwrap().iter().map(|i| i["preview"].as_str().unwrap()).collect();
|
||||
assert_eq!(previews, ["three", "two", "one"], "newest first");
|
||||
assert_eq!(run(&mut s, "edit.clipboardPane", json!({}))["value"], true);
|
||||
assert!(s.view.clipboard_pane);
|
||||
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "edit.pasteClipboardItem", json!({"index": 1}));
|
||||
assert_eq!(text(&s), "one two threetwo");
|
||||
assert_eq!(s.clipboard_text, "three", "the clipboard itself is unchanged");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "one two three");
|
||||
|
||||
run(&mut s, "edit.pasteAllClipboard", json!({}));
|
||||
assert_eq!(text(&s), "one two threeonetwothree", "Paste All goes in copy order");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "one two three", "Paste All is one undo step");
|
||||
|
||||
let left = run(&mut s, "edit.deleteClipboardItem", json!({"index": 0}));
|
||||
assert_eq!(left[0]["preview"], "two");
|
||||
run(&mut s, "edit.clearClipboard", json!({}));
|
||||
assert!(s.clip_history.is_empty());
|
||||
assert!(s.run("edit.pasteClipboardItem", &json!({"index": 0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_pane_is_capped_and_rejects_bad_indexes() {
|
||||
let mut s = s();
|
||||
let words: Vec<String> = (0..30).map(|i| format!("w{i}")).collect();
|
||||
run(&mut s, "document.setText", json!({"text": words.join(" ")}));
|
||||
// Cutting each word in turn leaves the spaces: word `off` starts at `off`.
|
||||
for (off, w) in words.iter().enumerate() {
|
||||
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": off}, "focus": {"block": 0, "off": off + w.len()}}));
|
||||
run(&mut s, "edit.cut", json!({}));
|
||||
}
|
||||
let items = run(&mut s, "edit.clipboardItems", json!({}));
|
||||
let items = items.as_array().unwrap();
|
||||
assert_eq!(items.len(), crate::cmd::edit::CLIP_MAX_ITEMS);
|
||||
assert_eq!(items[0]["preview"], "w29");
|
||||
assert_eq!(items[23]["preview"], "w6", "the oldest drop out");
|
||||
|
||||
let before = text(&s);
|
||||
for bad in [json!({"index": 24}), json!({"index": -1}), json!({"index": 1e300}), json!({"index": "x"}), json!({})] {
|
||||
assert!(s.run("edit.pasteClipboardItem", &bad).is_err(), "{bad}");
|
||||
assert!(s.run("edit.deleteClipboardItem", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(text(&s), before);
|
||||
assert_eq!(s.clip_history.len(), 24);
|
||||
|
||||
// A huge copy still reaches the clipboard but is not collected.
|
||||
run(&mut s, "document.setText", json!({"text": "x".repeat(3 << 20)}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
assert_eq!(s.clipboard_text.len(), 3 << 20);
|
||||
assert_eq!(s.clip_history.items()[0].text, "w29");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_commands() {
|
||||
let mut s = s();
|
||||
|
|
@ -1687,6 +1832,357 @@ fn styles_apply_rejected_on_protected_document() {
|
|||
assert_eq!(s.undo_labels(), undo);
|
||||
}
|
||||
|
||||
fn column_doc() -> Session {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "abcdef\nabcdef\nabcdef"}));
|
||||
s
|
||||
}
|
||||
|
||||
fn block(s: &mut Session) {
|
||||
let a = serde_json::to_value(Pos::body(0, 2)).unwrap();
|
||||
let f = serde_json::to_value(Pos::body(2, 4)).unwrap();
|
||||
let r = run(s, "select.column", json!({"anchor": a, "focus": f}));
|
||||
assert_eq!(r["rows"], json!(["cd", "cd", "cd"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_selection_copies_the_block() {
|
||||
let mut s = column_doc();
|
||||
block(&mut s);
|
||||
let r = run(&mut s, "edit.copy", json!({}));
|
||||
assert_eq!(r["text"], "cd\ncd\ncd");
|
||||
assert_eq!(s.clipboard.as_ref().map(|f| f.blocks.len()), Some(3));
|
||||
// A caret move drops the block; pasting the copy gives three paragraphs.
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
assert!(s.column_segments().is_none());
|
||||
s.autocorrect_on = false;
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "edit.paste", json!({}));
|
||||
assert_eq!(text(&s), "abcdef\nabcdef\nabcdef\ncd\ncd\ncd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_selection_deletes_only_the_block() {
|
||||
let mut s = column_doc();
|
||||
block(&mut s);
|
||||
run(&mut s, "text.delete", json!({}));
|
||||
assert_eq!(text(&s), "abef\nabef\nabef");
|
||||
assert_eq!(s.sel, crate::Selection::caret(Pos::body(0, 2)));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "abcdef\nabcdef\nabcdef");
|
||||
// Cut copies and deletes; typing replaces the block.
|
||||
block(&mut s);
|
||||
let r = run(&mut s, "edit.cut", json!({}));
|
||||
assert_eq!(r["text"], "cd\ncd\ncd");
|
||||
assert_eq!(text(&s), "abef\nabef\nabef");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
block(&mut s);
|
||||
run(&mut s, "text.insert", json!({"text": "X"}));
|
||||
assert_eq!(text(&s), "abXef\nabef\nabef");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_selection_formats_each_row() {
|
||||
let mut s = column_doc();
|
||||
block(&mut s);
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
for i in 0..3 {
|
||||
let p = s.doc.para_at(&Pos::body(i, 0)).unwrap();
|
||||
assert_ne!(p.props_of_char(1).bold, Some(true));
|
||||
assert_eq!(p.props_of_char(2).bold, Some(true));
|
||||
assert_eq!(p.props_of_char(3).bold, Some(true));
|
||||
assert_ne!(p.props_of_char(4).bold, Some(true));
|
||||
}
|
||||
// The block survives formatting, and a second Bold turns it off again.
|
||||
assert!(s.column_segments().is_some());
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
assert_ne!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props_of_char(2).bold, Some(true));
|
||||
run(&mut s, "format.changeCase", json!({"mode": "upper"}));
|
||||
assert_eq!(text(&s), "abCDef\nabCDef\nabCDef");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_mode_extends_with_the_arrow_keys() {
|
||||
let mut s = column_doc();
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 1}}));
|
||||
let r = run(&mut s, "select.column", json!({}));
|
||||
assert_eq!(r["columnMode"], true);
|
||||
run(&mut s, "caret.right", json!({}));
|
||||
run(&mut s, "caret.right", json!({}));
|
||||
run(&mut s, "caret.down", json!({}));
|
||||
let r = run(&mut s, "caret.down", json!({}));
|
||||
assert_eq!(r["rows"], json!(["bc", "bc", "bc"]));
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
assert_eq!(text(&s), "adef\nadef\nadef");
|
||||
assert!(!s.column_mode);
|
||||
// Escape leaves the mode.
|
||||
run(&mut s, "select.column", json!({}));
|
||||
run(&mut s, "select.collapse", json!({}));
|
||||
assert!(!s.column_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_selection_from_points() {
|
||||
let mut s = column_doc();
|
||||
let l = s.layout();
|
||||
let a = l.caret(&Pos::body(0, 1)).unwrap();
|
||||
let b = l.caret(&Pos::body(1, 5)).unwrap();
|
||||
let r = run(
|
||||
&mut s,
|
||||
"select.column",
|
||||
json!({"from": {"page": a.page, "x": a.x, "y": a.top + 2.0}, "to": {"page": b.page, "x": b.x, "y": b.top + 2.0}}),
|
||||
);
|
||||
assert_eq!(r["rows"], json!(["bcde", "bcde"]));
|
||||
for bad in [
|
||||
json!({"from": {"page": 99, "x": 1, "y": 1}, "to": {"page": 0, "x": 1, "y": 1}}),
|
||||
json!({"from": 1, "to": "x"}),
|
||||
json!({"anchor": 1, "focus": {}}),
|
||||
] {
|
||||
assert!(s.run("select.column", &bad).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_inspector_reports_and_clears_levels() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Chapter one"}));
|
||||
run(&mut s, "para.heading1", json!({}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "format.bold", json!({"value": true}));
|
||||
run(&mut s, "para.alignCenter", json!({}));
|
||||
|
||||
let r = run(&mut s, "styles.inspect", json!({}));
|
||||
assert_eq!(r["paragraph"]["style"], "Heading1");
|
||||
assert_eq!(r["paragraph"]["styleName"], "Heading 1");
|
||||
let pdirect = r["paragraph"]["direct"].as_array().unwrap();
|
||||
assert!(pdirect.iter().any(|d| d["prop"] == "align" && d["value"] == "center"), "{pdirect:?}");
|
||||
assert!(r["character"]["style"].is_null());
|
||||
let cdirect = r["character"]["direct"].as_array().unwrap();
|
||||
assert_eq!(cdirect, &vec![json!({"prop": "bold", "value": true})]);
|
||||
|
||||
// Formatting that matches the style isn't a difference (Heading 1 is 20 pt).
|
||||
run(&mut s, "format.size", json!({"size": 20}));
|
||||
let r = run(&mut s, "styles.inspect", json!({}));
|
||||
assert_eq!(r["character"]["direct"].as_array().unwrap().len(), 1, "{r}");
|
||||
|
||||
// Character style level.
|
||||
run(&mut s, "format.charStyle", json!({"style": "Emphasis"}));
|
||||
let r = run(&mut s, "styles.inspect", json!({}));
|
||||
assert_eq!(r["character"]["style"], "Emphasis");
|
||||
assert_eq!(r["character"]["styleName"], "Emphasis");
|
||||
run(&mut s, "styles.inspectorClear", json!({"level": "characterStyle"}));
|
||||
assert!(run(&mut s, "styles.inspect", json!({}))["character"]["style"].is_null());
|
||||
|
||||
// Clearing character formatting removes the bold and keeps the paragraph levels.
|
||||
run(&mut s, "styles.inspectorClear", json!({"level": "characterFormatting"}));
|
||||
let r = run(&mut s, "styles.inspect", json!({}));
|
||||
assert!(r["character"]["direct"].as_array().unwrap().is_empty(), "{r}");
|
||||
assert!(!run(&mut s, "format.state", json!({}))["bold"].as_bool().unwrap());
|
||||
assert_eq!(r["paragraph"]["style"], "Heading1");
|
||||
assert!(!r["paragraph"]["direct"].as_array().unwrap().is_empty());
|
||||
|
||||
run(&mut s, "styles.inspectorClear", json!({"level": "paragraphFormatting"}));
|
||||
let r = run(&mut s, "styles.inspect", json!({}));
|
||||
assert!(r["paragraph"]["direct"].as_array().unwrap().is_empty(), "{r}");
|
||||
assert_eq!(r["paragraph"]["style"], "Heading1");
|
||||
|
||||
run(&mut s, "styles.inspectorClear", json!({"level": "paragraphStyle"}));
|
||||
assert_eq!(run(&mut s, "styles.inspect", json!({}))["paragraph"]["style"], "Normal");
|
||||
|
||||
// Each clear is one undo step.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(run(&mut s, "styles.inspect", json!({}))["paragraph"]["style"], "Heading1");
|
||||
assert_eq!(text(&s), "Chapter one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_inspector_pane_and_hostile_params() {
|
||||
let mut s = Session::new(crate::sample::sample_document());
|
||||
let r = run(&mut s, "styles.inspector", json!({}));
|
||||
assert_eq!(r["pane"], true);
|
||||
assert!(s.view.style_inspector);
|
||||
assert_eq!(r["paragraph"]["styleName"], "Title");
|
||||
let r = run(&mut s, "styles.inspector", json!({"value": false}));
|
||||
assert_eq!(r["pane"], false);
|
||||
let before = text(&s);
|
||||
for junk in [json!(null), json!({}), json!({"level": 5}), json!({"level": "everything"}), json!({"level": ""})] {
|
||||
assert!(s.run("styles.inspectorClear", &junk).is_err(), "{junk}");
|
||||
}
|
||||
assert_eq!(text(&s), before);
|
||||
assert!(s.run("styles.inspector", &json!({"value": "yes"})).is_ok());
|
||||
// A selection reaching far outside the document.
|
||||
s.sel.focus = Pos::body(9999, 99999);
|
||||
s.sel.anchor = Pos::body(0, 0);
|
||||
assert!(s.run("styles.inspect", &json!({})).is_ok());
|
||||
assert!(s.run("styles.inspectorClear", &json!({"level": "characterFormatting"})).is_ok());
|
||||
}
|
||||
|
||||
/// Home › Editing › Find › Advanced Find: every match, in the body or a selection, with Reading Highlight.
|
||||
#[test]
|
||||
fn advanced_find_lists_and_highlights_matches() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "cat dog cat"}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "Cat"}));
|
||||
// From the UI it opens the dialog.
|
||||
run(&mut s, "edit.advancedFind", json!({}));
|
||||
assert_eq!(s.ui_requests.last(), Some(&json!({"open": "find"})));
|
||||
let r = run(&mut s, "edit.advancedFind", json!({"text": "cat", "highlight": true}));
|
||||
assert_eq!(r["count"], 3);
|
||||
assert_eq!(s.find_highlights().len(), 3);
|
||||
assert_eq!(run(&mut s, "edit.advancedFind", json!({"text": "cat", "matchCase": true}))["count"], 2);
|
||||
// Highlights follow edits and the latest search.
|
||||
assert_eq!(s.find_highlights().len(), 2);
|
||||
run(&mut s, "text.insert", json!({"text": " cat"}));
|
||||
assert_eq!(s.find_highlights().len(), 3);
|
||||
// Only inside the selection.
|
||||
run(&mut s, "select.text", json!({"text": "cat dog"}));
|
||||
assert_eq!(run(&mut s, "edit.advancedFind", json!({"text": "cat", "in": "selection"}))["count"], 1);
|
||||
run(&mut s, "edit.advancedFind", json!({"highlight": false}));
|
||||
assert!(s.find_highlights().is_empty());
|
||||
// Hostile params.
|
||||
assert!(s.run("edit.advancedFind", &json!({"text": "x", "in": "elsewhere"})).is_err());
|
||||
assert!(s.run("edit.advancedFind", &json!({"text": "(", "regex": true})).is_err());
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "text.delete", json!({}));
|
||||
assert!(s.run("edit.advancedFind", &json!({"text": "x", "in": "selection"})).is_err());
|
||||
}
|
||||
|
||||
/// #146: a table style made by command styles the table at the caret (header row fill and bold
|
||||
/// text, banded rows), can be modified, and saves as a table style the table refers to.
|
||||
#[test]
|
||||
fn custom_table_style_applies_modifies_and_saves() {
|
||||
use wordcraft_doc::{Rgb, StyleKind, TextColor};
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 3, "cols": 2}));
|
||||
run(&mut s, "text.insert", json!({"text": "Head"}));
|
||||
run(&mut s, "table.look", json!({"headerRow": true, "bandedRows": true}));
|
||||
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
|
||||
let r = run(
|
||||
&mut s,
|
||||
"table.newStyle",
|
||||
json!({"name": "Thesis Table", "basedOn": "Table Grid",
|
||||
"wholeTable": {"size": 10},
|
||||
"headerRow": {"fill": "1F3864", "bold": true, "color": "FFFFFF", "borders": true},
|
||||
"bandedRows": {"fill": "EEEEEE", "italic": true}}),
|
||||
);
|
||||
let id = r["id"].as_str().unwrap().to_string();
|
||||
let table_style = |s: &Session| s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone();
|
||||
assert_eq!(table_style(&s).as_deref(), Some(id.as_str()), "applied to the current table");
|
||||
assert!(s.run("table.newStyle", &json!({"name": "thesis table"})).is_err(), "names are unique");
|
||||
assert!(s.run("table.newStyle", &json!({"name": "X", "basedOn": "Heading 1"})).is_err(), "bases are table styles");
|
||||
assert!(s.run("table.style", &json!({"style": "Normal"})).is_err(), "only table styles apply to tables");
|
||||
|
||||
let fills = |s: &mut Session, c: Rgb| {
|
||||
s.layout().pages[0].items.iter().filter(|i| matches!(i, crate::layout::Placed::Fill { color, .. } if *color == c)).count()
|
||||
};
|
||||
let head = |s: &mut Session| {
|
||||
let mut p = tp.0.clone();
|
||||
p.extend([0, 0, 0]);
|
||||
s.layout().pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.find_map(
|
||||
|i| if let crate::layout::Placed::Lines { path, para, .. } = i { (path.0 == p).then(|| para.styles[0].rc.clone()) } else { None },
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(fills(&mut s, Rgb(0x1F, 0x38, 0x64)), 2, "header cells");
|
||||
assert_eq!(fills(&mut s, Rgb(0xEE, 0xEE, 0xEE)), 2, "first band");
|
||||
let rc = head(&mut s);
|
||||
assert!(rc.bold && rc.size == 10.0, "{rc:?}");
|
||||
assert_eq!(rc.color, TextColor::Rgb(Rgb::WHITE));
|
||||
|
||||
// Modify the current table's style: the table follows.
|
||||
run(&mut s, "table.modifyStyle", json!({"headerRow": {"fill": "C00000", "bold": false}, "bandSize": 2}));
|
||||
assert_eq!(fills(&mut s, Rgb(0x1F, 0x38, 0x64)), 0);
|
||||
assert_eq!(fills(&mut s, Rgb(0xC0, 0, 0)), 2);
|
||||
assert_eq!(fills(&mut s, Rgb(0xEE, 0xEE, 0xEE)), 4, "two rows per band");
|
||||
assert!(!head(&mut s).bold);
|
||||
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||||
let st = back.styles.get(&id).unwrap();
|
||||
assert_eq!((st.kind, st.name.as_str(), st.based_on.as_deref()), (StyleKind::Table, "Thesis Table", Some("TableGrid")));
|
||||
let parts = st.table.as_ref().unwrap();
|
||||
assert_eq!((parts.header_fill, parts.band_fill, parts.band_size), (Some(Rgb(0xC0, 0, 0)), Some(Rgb(0xEE, 0xEE, 0xEE)), Some(2)));
|
||||
assert_eq!((parts.header_chr.bold, parts.band_chr.italic), (Some(false), Some(true)));
|
||||
assert!(parts.header_borders.is_some_and(|b| b.any_visible()));
|
||||
let t = back.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
assert_eq!(t.props.style.as_deref(), Some(id.as_str()), "w:tblStyle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_and_table_gridlines_toggle_independently() {
|
||||
// #69: View › Gridlines (the page drawing grid) and Table Layout › View Gridlines (table cell
|
||||
// outlines) are separate view switches; neither touches the document or the undo stack.
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
let undo = s.undo_labels();
|
||||
assert!(!s.view.gridlines && !s.view.table_gridlines);
|
||||
|
||||
assert_eq!(run(&mut s, "view.gridlines", json!({}))["value"], true);
|
||||
assert!(s.view.gridlines && !s.view.table_gridlines);
|
||||
|
||||
// Works with the caret outside a table.
|
||||
assert_eq!(run(&mut s, "table.viewGridlines", json!({}))["value"], true);
|
||||
assert!(s.view.gridlines && s.view.table_gridlines);
|
||||
|
||||
assert_eq!(run(&mut s, "view.gridlines", json!({"value": false}))["value"], false);
|
||||
assert!(!s.view.gridlines && s.view.table_gridlines);
|
||||
assert_eq!(run(&mut s, "view.gridlines", json!({"value": false}))["value"], false);
|
||||
assert!(!s.view.gridlines);
|
||||
|
||||
assert_eq!(run(&mut s, "table.viewGridlines", json!({}))["value"], false);
|
||||
assert!(!s.view.gridlines && !s.view.table_gridlines);
|
||||
|
||||
assert_eq!(text(&s), "Hello");
|
||||
assert_eq!(s.undo_labels(), undo);
|
||||
let state = run(&mut s, "view.state", json!({}));
|
||||
assert_eq!(state["gridlines"], false);
|
||||
assert_eq!(state["tableGridlines"], false);
|
||||
}
|
||||
|
||||
/// Issue #67: View › Zoom steps. Zoom In/Out leave a fit mode and step 10% from the current zoom
|
||||
/// (the UI keeps `view.zoom` equal to the shown zoom while a fit mode is on), and never get stuck
|
||||
/// short of the 10%–500% limits.
|
||||
#[test]
|
||||
fn zoom_in_and_out_step_from_current_zoom_and_leave_fit_modes() {
|
||||
let mut s = s();
|
||||
let pct = |s: &Session| (s.view.zoom * 100.0).round() as i32;
|
||||
run(&mut s, "view.zoomIn", json!({}));
|
||||
assert_eq!(pct(&s), 110);
|
||||
run(&mut s, "view.zoomOut", json!({}));
|
||||
run(&mut s, "view.zoomOut", json!({}));
|
||||
assert_eq!(pct(&s), 90);
|
||||
for fit in ["view.pageWidth", "view.onePage", "view.multiplePages"] {
|
||||
run(&mut s, fit, json!({}));
|
||||
assert!(!s.view.fit.is_empty());
|
||||
// What the canvas reports while a fit mode shows the page at 163%.
|
||||
s.view.zoom = 1.63;
|
||||
run(&mut s, "view.zoomIn", json!({}));
|
||||
assert_eq!((pct(&s), s.view.fit.as_str(), s.view.multi_page), (170, "", false), "{fit}");
|
||||
run(&mut s, fit, json!({}));
|
||||
s.view.zoom = 1.63;
|
||||
run(&mut s, "view.zoomOut", json!({}));
|
||||
assert_eq!((pct(&s), s.view.fit.as_str()), (150, ""), "{fit}");
|
||||
}
|
||||
let mut last = pct(&s);
|
||||
for _ in 0..60 {
|
||||
run(&mut s, "view.zoomIn", json!({}));
|
||||
assert!(pct(&s) > last || pct(&s) == 500);
|
||||
last = pct(&s);
|
||||
}
|
||||
assert_eq!(last, 500);
|
||||
for _ in 0..60 {
|
||||
run(&mut s, "view.zoomOut", json!({}));
|
||||
assert!(pct(&s) < last || pct(&s) == 10);
|
||||
last = pct(&s);
|
||||
}
|
||||
assert_eq!(last, 10);
|
||||
run(&mut s, "view.zoom100", json!({}));
|
||||
assert_eq!(pct(&s), 100);
|
||||
}
|
||||
|
||||
/// Comments, revisions and dates read the real clock (#188: the web stamped 2026-01-01).
|
||||
#[test]
|
||||
fn timestamps_come_from_the_clock() {
|
||||
|
|
@ -1739,3 +2235,201 @@ fn shape_effects_apply_and_undo() {
|
|||
run(&mut s, "text.insert", json!({"text": "x"}));
|
||||
assert!(s.run("shape.effects", &json!({"shadow": null})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asian_typography_sets_flags_on_selected_paragraphs_and_undoes() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "一\n二\n三"}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
let r = run(&mut s, "para.asianTypography", json!({"kinsoku": false, "wordWrap": false, "topLinePunct": true}));
|
||||
assert_eq!(r["asianTypography"]["kinsoku"], false);
|
||||
assert_eq!(r["asianTypography"]["autoSpaceDE"], true, "a flag left out keeps its default");
|
||||
for p in s.doc.body.iter().filter_map(|b| b.as_para()) {
|
||||
assert_eq!(
|
||||
(p.props.kinsoku, p.props.word_wrap, p.props.top_line_punct, p.props.overflow_punct),
|
||||
(Some(false), Some(false), Some(true), None)
|
||||
);
|
||||
}
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.kinsoku.is_none() && p.props.word_wrap.is_none()));
|
||||
// Without flags it only reports them.
|
||||
let r = run(&mut s, "para.asianTypography", json!({}));
|
||||
assert_eq!(r["asianTypography"]["kinsoku"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manage_styles_lists_paragraph_character_linked_and_table_styles() {
|
||||
let mut s = s();
|
||||
let list = run(&mut s, "styles.manage", json!({"sort": "alphabetical"}));
|
||||
let list = list.as_array().unwrap();
|
||||
let find = |name: &str| list.iter().find(|x| x["name"] == name).unwrap_or_else(|| panic!("{name} listed"));
|
||||
assert_eq!(find("Normal")["type"], "paragraph");
|
||||
assert_eq!(find("Normal")["builtIn"], true);
|
||||
assert_eq!(find("Normal")["inGallery"], true);
|
||||
assert_eq!(find("Heading 1")["type"], "linked");
|
||||
assert_eq!(find("Heading 1")["basedOn"], "Normal");
|
||||
assert!(find("Heading 1")["format"]["size"].is_number(), "description formatting");
|
||||
assert_eq!(find("Table Grid")["type"], "table");
|
||||
assert!(list.iter().any(|x| x["type"] == "character"));
|
||||
// The character half of a linked pair is listed once, as the linked style.
|
||||
assert!(!list.iter().any(|x| x["id"] == "Heading1Char"));
|
||||
let names: Vec<String> = list.iter().map(|x| x["name"].as_str().unwrap().to_lowercase()).collect();
|
||||
assert!(names.windows(2).all(|w| w[0] <= w[1]), "alphabetical");
|
||||
// Recommended order starts with Normal (priority 0); listing is not an edit.
|
||||
let rec = run(&mut s, "styles.manage", json!({}));
|
||||
assert_eq!(rec[0]["name"], "Normal");
|
||||
assert!(s.run("styles.manage", &json!({"sort": "sideways"})).is_err());
|
||||
assert!(!s.dirty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_style_falls_back_to_its_base_and_undoes() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Styled"}));
|
||||
run(&mut s, "styles.create", json!({"name": "Mine", "basedOn": "Heading 1"}));
|
||||
run(&mut s, "styles.create", json!({"name": "Child", "basedOn": "Mine", "fromSelection": false}));
|
||||
run(&mut s, "styles.apply", json!({"style": "Mine"}));
|
||||
assert_eq!(para_style(&s, 0).as_deref(), Some("Mine"));
|
||||
let r = run(&mut s, "styles.delete", json!({"style": "Mine"}));
|
||||
assert_eq!(r["restyled"], 1);
|
||||
assert!(s.doc.styles.get("Mine").is_none());
|
||||
assert_eq!(para_style(&s, 0).as_deref(), Some("Heading1"), "falls back to its base");
|
||||
assert_eq!(s.doc.styles.get("Child").and_then(|c| c.based_on.clone()).as_deref(), Some("Heading1"), "rebased");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.styles.get("Mine").is_some());
|
||||
assert_eq!(para_style(&s, 0).as_deref(), Some("Mine"));
|
||||
// Built-in styles are refused, unknown ones are errors.
|
||||
assert!(s.run("styles.delete", &json!({"style": "Heading 1"})).is_err());
|
||||
assert!(s.run("styles.delete", &json!({"style": "No Such Style"})).is_err());
|
||||
assert!(s.doc.styles.get("Heading1").is_some());
|
||||
}
|
||||
|
||||
/// Manage Styles' Delete on a table style is Table Design's Delete Table Style: its tables take
|
||||
/// Table Grid, styles based on it keep their look, and Word's built-in table styles are refused.
|
||||
#[test]
|
||||
fn deleting_a_table_style_from_manage_styles_uses_table_style_deletion() {
|
||||
use wordcraft_doc::Rgb;
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
|
||||
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
|
||||
run(&mut s, "table.newStyle", json!({"name": "Base", "headerRow": {"fill": "C00000"}}));
|
||||
run(&mut s, "table.newStyle", json!({"name": "Child", "basedOn": "Base", "apply": false}));
|
||||
let r = run(&mut s, "styles.delete", json!({"style": "Base"}));
|
||||
assert_eq!((r["deleted"][0].as_str(), r["restyled"].as_u64()), (Some("Base"), Some(1)));
|
||||
assert_eq!(s.doc.table(StoryRef::Body, &tp).unwrap().props.style.as_deref(), Some("TableGrid"));
|
||||
assert_eq!(s.doc.styles.table_style("Child").unwrap().parts.header_fill, Some(Rgb(0xC0, 0, 0)), "the child keeps its look");
|
||||
for st in s.doc.styles.styles.iter_mut() {
|
||||
st.builtin = false;
|
||||
}
|
||||
assert!(s.run("styles.delete", &json!({"style": "Table Grid"})).is_err());
|
||||
let list = run(&mut s, "styles.manage", json!({}));
|
||||
let grid = list.as_array().unwrap().iter().find(|x| x["name"] == "Table Grid").unwrap();
|
||||
assert_eq!(grid["builtIn"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_visibility_round_trips_through_docx() {
|
||||
let mut s = s();
|
||||
run(&mut s, "styles.create", json!({"name": "Mine", "fromSelection": false}));
|
||||
run(&mut s, "styles.setVisibility", json!({"style": "Heading 1", "gallery": false}));
|
||||
run(&mut s, "styles.setVisibility", json!({"style": "Mine", "hidden": true}));
|
||||
run(&mut s, "styles.setVisibility", json!({"style": "Heading 4", "gallery": true}));
|
||||
assert!(s.run("styles.setVisibility", &json!({"style": "Mine"})).is_err());
|
||||
let gallery: Vec<String> = s.doc.styles.gallery().iter().map(|x| x.id.clone()).collect();
|
||||
assert!(!gallery.contains(&"Heading1".to_string()) && !gallery.contains(&"Mine".to_string()) && gallery.contains(&"Heading4".to_string()));
|
||||
let bytes = crate::io::save_bytes("v.docx", &s.doc).unwrap();
|
||||
let doc = crate::io::open_bytes("v.docx", &bytes).unwrap();
|
||||
let st = |id: &str| doc.styles.get(id).cloned().unwrap_or_else(|| panic!("{id} kept"));
|
||||
assert!(!st("Heading1").quick && !st("Heading1").hidden);
|
||||
assert!(st("Mine").hidden && !st("Mine").builtin);
|
||||
assert!(st("Heading4").quick);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.styles.get("Heading4").is_some_and(|h| !h.quick), "undoable");
|
||||
}
|
||||
|
||||
/// #146: the first/last column, total row and banded column regions apply in layout (later
|
||||
/// regions win: the total row's fill beats the first column's) and round-trip through .docx.
|
||||
#[test]
|
||||
fn custom_table_style_column_and_total_regions() {
|
||||
use wordcraft_doc::Rgb;
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 3, "cols": 4}));
|
||||
run(
|
||||
&mut s,
|
||||
"table.look",
|
||||
json!({"headerRow": false, "bandedRows": false, "firstColumn": true, "lastColumn": true, "totalRow": true, "bandedColumns": true}),
|
||||
);
|
||||
let r = run(
|
||||
&mut s,
|
||||
"table.newStyle",
|
||||
json!({"name": "Ledger",
|
||||
"firstColumn": {"fill": "112233", "bold": true},
|
||||
"lastColumn": {"fill": "445566"},
|
||||
"lastRow": {"fill": "778899", "borders": true},
|
||||
"bandedColumns": {"fill": "ABCDEF"}}),
|
||||
);
|
||||
let id = r["id"].as_str().unwrap().to_string();
|
||||
let fills = |s: &mut Session, c: Rgb| {
|
||||
s.layout().pages[0].items.iter().filter(|i| matches!(i, crate::layout::Placed::Fill { color, .. } if *color == c)).count()
|
||||
};
|
||||
// 3 rows x 4 columns: the total row takes all 4 of its cells, the first and last columns the
|
||||
// two cells above it, and the column band (column 2 of the inner columns 2 and 3) one each.
|
||||
assert_eq!(fills(&mut s, Rgb(0x77, 0x88, 0x99)), 4, "total row");
|
||||
assert_eq!(fills(&mut s, Rgb(0x11, 0x22, 0x33)), 2, "first column");
|
||||
assert_eq!(fills(&mut s, Rgb(0x44, 0x55, 0x66)), 2, "last column");
|
||||
assert_eq!(fills(&mut s, Rgb(0xAB, 0xCD, 0xEF)), 2, "first column band");
|
||||
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||||
let p = back.styles.get(&id).unwrap().table.clone().unwrap();
|
||||
assert_eq!(
|
||||
(p.first_col_fill, p.first_col_chr.bold, p.last_col_fill, p.total_fill, p.col_band_fill),
|
||||
(Some(Rgb(0x11, 0x22, 0x33)), Some(true), Some(Rgb(0x44, 0x55, 0x66)), Some(Rgb(0x77, 0x88, 0x99)), Some(Rgb(0xAB, 0xCD, 0xEF)))
|
||||
);
|
||||
assert!(p.total_borders.is_some_and(|b| b.any_visible()));
|
||||
}
|
||||
|
||||
/// #146: deleting a custom table style re-bases the styles based on it (keeping their look),
|
||||
/// puts its tables on Table Grid, refuses built-in styles, and undoes.
|
||||
#[test]
|
||||
fn delete_table_style_falls_back_and_undoes() {
|
||||
use wordcraft_doc::Rgb;
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
|
||||
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
|
||||
run(&mut s, "table.newStyle", json!({"name": "Base", "headerRow": {"fill": "C00000"}}));
|
||||
run(&mut s, "table.newStyle", json!({"name": "Child", "basedOn": "Base", "apply": false, "wholeTable": {"bold": true}}));
|
||||
let style = |s: &Session| s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone();
|
||||
assert_eq!(style(&s).as_deref(), Some("Base"));
|
||||
assert!(s.run("table.deleteStyle", &json!({"style": "Table Grid"})).is_err(), "built-in");
|
||||
assert!(s.run("table.deleteStyle", &json!({"style": "Normal Table"})).is_err(), "built-in");
|
||||
|
||||
let r = run(&mut s, "table.deleteStyle", json!({}));
|
||||
assert_eq!((r["deleted"].as_str(), r["tables"].as_u64()), (Some("Base"), Some(1)));
|
||||
assert!(s.doc.styles.get("Base").is_none());
|
||||
assert_eq!(style(&s).as_deref(), Some("TableGrid"), "tables fall back to Table Grid");
|
||||
let child = s.doc.styles.table_style("Child").unwrap();
|
||||
assert_eq!((child.parts.header_fill, child.chr.bold), (Some(Rgb(0xC0, 0, 0)), Some(true)), "the child keeps its look");
|
||||
assert_eq!(s.doc.styles.get("Child").unwrap().based_on.as_deref(), Some("TableGrid"));
|
||||
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.styles.get("Base").is_some());
|
||||
assert_eq!(style(&s).as_deref(), Some("Base"));
|
||||
assert_eq!(s.doc.styles.get("Child").unwrap().based_on.as_deref(), Some("Base"));
|
||||
}
|
||||
|
||||
/// #146: a .docx may mark Word's built-in table styles as custom; they still can't be deleted, so
|
||||
/// no table is left pointing at a style that's gone.
|
||||
#[test]
|
||||
fn builtin_table_styles_marked_custom_in_a_docx_cant_be_deleted() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
|
||||
for st in s.doc.styles.styles.iter_mut() {
|
||||
st.builtin = false;
|
||||
}
|
||||
for name in ["Table Grid", "Normal Table", "TableGrid"] {
|
||||
assert!(s.run("table.deleteStyle", &json!({"style": name})).is_err(), "{name}");
|
||||
}
|
||||
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
|
||||
let id = s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone().unwrap();
|
||||
assert!(s.doc.styles.get(&id).is_some(), "the table's style still exists");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
use wordcraft_doc::para::{InlineObject, ShapeKind};
|
||||
use wordcraft_doc::props::{Border, BorderStyle, Rgb, TextColor, TextDirection, Underline};
|
||||
use wordcraft_doc::{Document, Path, StoryRef};
|
||||
use wordcraft_doc::section::SectionStart;
|
||||
use wordcraft_doc::{Block, Document, Path, StoryRef};
|
||||
use wordcraft_fonts::FaceRef;
|
||||
use wordcraft_geom::Rect;
|
||||
|
||||
|
|
@ -84,6 +85,14 @@ pub enum Draw {
|
|||
turn: TextDirection,
|
||||
items: Vec<Draw>,
|
||||
},
|
||||
/// A formatting-mark label (a section break's name) in the UI's mark colour, left edge at
|
||||
/// `x`, drawn with the same face as [`Draw::Mark`]. Screen only, like every mark.
|
||||
MarkText {
|
||||
x: f32,
|
||||
baseline: f32,
|
||||
size: f32,
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Draw {
|
||||
|
|
@ -345,26 +354,36 @@ fn lines(
|
|||
if matches!(c.kind, ClKind::Text | ClKind::Space) {
|
||||
let a = text.len();
|
||||
// An object's cluster (a field, a note number) stands for the text it shows.
|
||||
let mut shown = false;
|
||||
if let Ok(i) = pl.shown.binary_search_by_key(&k, |(i, _)| *i)
|
||||
&& let Some((_, s)) = pl.shown.get(i)
|
||||
let mut shown = None;
|
||||
if let Ok(i) = pl.shown.binary_search_by_key(&k, |(i, _, _)| *i)
|
||||
&& let Some((_, s, r)) = pl.shown.get(i)
|
||||
{
|
||||
text.push_str(s);
|
||||
shown = true;
|
||||
shown = Some(r);
|
||||
} else if let Some(p) = para {
|
||||
text.push_str(p.text.get(c.start..c.end).unwrap_or(""));
|
||||
}
|
||||
let b = text.len();
|
||||
let cg = pl.glyphs.get(c.g0 as usize..c.g1 as usize).unwrap_or(&[]);
|
||||
if shown && cg.len() > 1 {
|
||||
// A whole field result is one cluster of many glyphs: give them a character
|
||||
// each (the last takes any remainder), not the whole text as one ligature.
|
||||
if let Some(shown) = shown
|
||||
&& cg.len() > 1
|
||||
{
|
||||
// A whole field result is one cluster of many glyphs: each glyph gets the
|
||||
// text it was shaped from (a ligature its letters), not the whole text as
|
||||
// one ligature. Without that, a character each (the last takes any remainder).
|
||||
let bounds: Vec<usize> = text.get(a..b).unwrap_or("").char_indices().map(|(i, _)| a + i).collect();
|
||||
let known = shown.len() == cg.len();
|
||||
for (n, g) in cg.iter().enumerate() {
|
||||
let from = bounds.get(n).or(bounds.last()).copied().unwrap_or(a);
|
||||
let to = if n + 1 == cg.len() { b } else { bounds.get(n + 1).copied().unwrap_or(b) };
|
||||
let r = match shown.get(n).filter(|_| known) {
|
||||
Some(r) => (a + r.start).min(b)..(a + r.end).min(b),
|
||||
None => {
|
||||
let from = bounds.get(n).or(bounds.last()).copied().unwrap_or(a);
|
||||
let to = if n + 1 == cg.len() { b } else { bounds.get(n + 1).copied().unwrap_or(b) };
|
||||
from..to
|
||||
}
|
||||
};
|
||||
glyphs.push((g.gid, cx + g.dx, base - st.shift - g.dy));
|
||||
ranges.push(from..to.max(from));
|
||||
ranges.push(r.start..r.end.max(r.start));
|
||||
}
|
||||
k += 1;
|
||||
continue;
|
||||
|
|
@ -517,6 +536,23 @@ fn lines(
|
|||
Some(InlineObject::Shape { kind, fill, stroke, stroke_width, effects, .. }) => {
|
||||
out.push(Draw::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width, effects: *effects })
|
||||
}
|
||||
Some(g @ InlineObject::Group { .. }) => {
|
||||
for ([x, y, w, h], c) in g.group_rects(rect.x, rect.y, rect.w, rect.h) {
|
||||
let rect = Rect::new(x, y, w, h);
|
||||
match c {
|
||||
InlineObject::Image { media, crop, .. } => out.push(Draw::Image { rect, media: media.clone(), crop: *crop, alpha }),
|
||||
InlineObject::Shape { kind, fill, stroke, stroke_width, effects, .. } => out.push(Draw::Shape {
|
||||
rect,
|
||||
kind: *kind,
|
||||
fill: *fill,
|
||||
stroke: *stroke,
|
||||
stroke_width: *stroke_width,
|
||||
effects: *effects,
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(InlineObject::Equation { .. }) => {
|
||||
if let Some((_, ml)) = pl.maths.iter().find(|(k, _)| *k == oi) {
|
||||
equation(&ml.items, cx, base, alpha, opts.placeholders, out);
|
||||
|
|
@ -560,7 +596,9 @@ fn lines(
|
|||
}
|
||||
}
|
||||
if line.end == LineEnd::Para {
|
||||
let ex = x + line.end_x();
|
||||
// The mark follows the line's visual end, past every glyph, even where the
|
||||
// logically last text runs against the paragraph's direction.
|
||||
let ex = x + line.visual_end_x();
|
||||
let size = pl.lines.first().map(|_| msize).unwrap_or(msize);
|
||||
// A right-to-left paragraph's mark sits at its end, on the left.
|
||||
let mx = if line.rtl { ex - 1.0 - size * 0.6 } else { ex + 1.0 };
|
||||
|
|
@ -569,13 +607,75 @@ fn lines(
|
|||
.and_then(|p| p.mark.ins.or(p.mark.del))
|
||||
.filter(|_| opts.markup)
|
||||
.map(|r| revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0)));
|
||||
out.push(Draw::Mark { x: mx, baseline: base, size, ch: '¶', color });
|
||||
// The last paragraph of a section ends in a section break instead of a plain ¶.
|
||||
if let Some(start) = section_break_after(doc, story, path) {
|
||||
let (x0, x1) = if line.rtl { (x + line.left, ex - 2.0) } else { (ex + 2.0, x + line.right) };
|
||||
section_break_mark(start, x0, x1, base, size, alpha, out);
|
||||
} else {
|
||||
out.push(Draw::Mark { x: mx, baseline: base, size, ch: '¶', color });
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = bottom;
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind of section break that ends at body paragraph `path`: the start type of the section
|
||||
/// that follows it. `None` for any other paragraph, and for the document's last section.
|
||||
fn section_break_after(doc: &Document, story: StoryRef, path: &Path) -> Option<SectionStart> {
|
||||
if story != StoryRef::Body || path.0.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let i = *path.0.first()? as usize;
|
||||
match doc.body.get(i).map(|b| &**b) {
|
||||
Some(Block::Para(p)) if p.section.is_some() => {}
|
||||
_ => return None,
|
||||
}
|
||||
let secs = doc.sections();
|
||||
let k = secs.iter().position(|(end, _)| *end == i)?;
|
||||
secs.get(k + 1).map(|(_, s)| s.start)
|
||||
}
|
||||
|
||||
/// On-screen name of a section break.
|
||||
pub fn section_break_label(start: SectionStart) -> &'static str {
|
||||
match start {
|
||||
SectionStart::NextPage => "Section Break (Next Page)",
|
||||
SectionStart::Continuous => "Section Break (Continuous)",
|
||||
SectionStart::EvenPage => "Section Break (Even Page)",
|
||||
SectionStart::OddPage => "Section Break (Odd Page)",
|
||||
SectionStart::NextColumn => "Section Break (Next Column)",
|
||||
}
|
||||
}
|
||||
|
||||
/// Width of a [`Draw::MarkText`] label.
|
||||
pub fn mark_text_width(text: &str, size: f32) -> f32 {
|
||||
let face = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face;
|
||||
let k = size / face.upem.max(1.0) as f32;
|
||||
text.chars().map(|c| face.advance(face.glyph_for(c)) as f32 * k).sum()
|
||||
}
|
||||
|
||||
/// A section break mark between `x0` and `x1`: a dotted double rule with the break's name centred
|
||||
/// in it (just the name when there is no room for the rule).
|
||||
fn section_break_mark(start: SectionStart, x0: f32, x1: f32, base: f32, size: f32, alpha: f32, out: &mut Vec<Draw>) {
|
||||
let text = section_break_label(start);
|
||||
let lsize = (size * 0.8).clamp(6.0, 11.0);
|
||||
let w = mark_text_width(text, lsize);
|
||||
let gap = lsize * 0.4;
|
||||
let mid = base - size * 0.3;
|
||||
let color = Rgb(0x60, 0x60, 0x60);
|
||||
if x1 - x0 <= w + 4.0 * gap {
|
||||
out.push(Draw::MarkText { x: x0, baseline: base, size: lsize, text: text.into() });
|
||||
return;
|
||||
}
|
||||
let tx = (x0 + x1 - w) / 2.0;
|
||||
for (a, b) in [(x0, tx - gap), (tx + w + gap, x1)] {
|
||||
for y in [mid - 1.2, mid + 1.2] {
|
||||
out.push(Draw::Line { x0: a, y0: y, x1: b, y1: y, width: 0.5, color, stroke: Stroke::Dotted, alpha });
|
||||
}
|
||||
}
|
||||
out.push(Draw::MarkText { x: tx, baseline: mid + lsize * 0.33, size: lsize, text: text.into() });
|
||||
}
|
||||
|
||||
/// An equation's glyphs and rules with its origin at (`x`, `base`).
|
||||
fn equation(items: &[MItem], x: f32, base: f32, alpha: f32, screen: bool, out: &mut Vec<Draw>) {
|
||||
for it in items {
|
||||
|
|
@ -640,9 +740,6 @@ pub fn text_color(c: &TextColor, background: Option<Rgb>) -> Rgb {
|
|||
/// Where inline object `obj` is drawn, given its cluster's box (`adv` × `obj_h` standing on the
|
||||
/// baseline at `cx`): inside the room kept for its effects.
|
||||
pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, obj_h: f32) -> Rect {
|
||||
let [l, t, r, b] = match obj {
|
||||
Some(InlineObject::Image { float, .. } | InlineObject::Shape { float, .. }) => float.effect_extent(),
|
||||
_ => [0.0; 4],
|
||||
};
|
||||
let [l, t, r, b] = obj.and_then(InlineObject::frame).map_or([0.0; 4], |(_, _, float)| float.effect_extent());
|
||||
Rect::new(cx + l, base - obj_h + t, (adv - l - r).max(0.0), (obj_h - t - b).max(0.0))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -373,6 +373,40 @@ impl DocLayout {
|
|||
None
|
||||
}
|
||||
|
||||
/// Column (block) selection: the piece of every line from the line holding `a` to the line
|
||||
/// holding `b` (document order) that lies between page x `left` and `right`. One `(start,
|
||||
/// end)` pair per line, both in the same paragraph; lines shorter than `left` give an empty
|
||||
/// pair. At most `max` lines.
|
||||
pub fn column_segments(&self, doc: &Document, a: &Pos, b: &Pos, left: f32, right: f32, page_hint: usize, max: usize) -> Vec<(Pos, Pos)> {
|
||||
let (a, b) = if a <= b { (a, b) } else { (b, a) };
|
||||
let (left, right) = if left <= right { (left, right) } else { (right, left) };
|
||||
let mut out = Vec::new();
|
||||
if a.story != b.story || !left.is_finite() || !right.is_finite() {
|
||||
return out;
|
||||
}
|
||||
for path in doc.paths_between(a, b) {
|
||||
for (_, it) in self.pieces(a.story, &path, page_hint) {
|
||||
let Placed::Lines { para, l0, l1, x, .. } = it else { continue };
|
||||
let first = if path == a.path { para.line_of(a.off) } else { 0 };
|
||||
let last = if path == b.path { para.line_of(b.off) } else { usize::MAX };
|
||||
for li in *l0..*l1 {
|
||||
if li < first || li > last {
|
||||
continue;
|
||||
}
|
||||
if out.len() >= max {
|
||||
return out;
|
||||
}
|
||||
let o0 = para.off_at_x(li, left - x);
|
||||
let o1 = para.off_at_x(li, right - x);
|
||||
let (o0, o1) = if o0 <= o1 { (o0, o1) } else { (o1, o0) };
|
||||
let mk = |off| Pos { story: a.story, path: path.clone(), off };
|
||||
out.push((mk(o0), mk(o1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Highlight rectangles for the selection `a..b`, per page.
|
||||
pub fn selection_rects(&self, doc: &Document, a: &Pos, b: &Pos, page_hint: usize) -> Vec<(usize, Rect)> {
|
||||
let (a, b) = if a <= b { (a, b) } else { (b, a) };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
//! Asian typography line-breaking rules (the paragraph's `kinsoku` and `word_wrap` flags), applied
|
||||
//! on top of the Unicode line-break opportunities (UAX #14).
|
||||
//!
|
||||
//! - Kinsoku on (the default): no line starts with closing East Asian punctuation (、。,)」…)
|
||||
//! and none ends with opening punctuation ((「【…). UAX #14 already forbids most of these
|
||||
//! breaks; the lists make sure of it for the standard Japanese/Chinese sets.
|
||||
//! - Kinsoku off: a line may break between any two East Asian characters, punctuation included.
|
||||
//! - Word wrap off: Latin words may break at any character.
|
||||
//!
|
||||
//! Only East Asian characters are in the lists, and the extra breaks need letters on both sides,
|
||||
//! so text with no East Asian characters breaks exactly as before unless word wrap is off.
|
||||
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
/// East Asian characters a line may not start with: closing brackets and quotes, the
|
||||
/// ideographic comma and full stop, fullwidth and halfwidth punctuation, the middle dot,
|
||||
/// iteration and sound marks.
|
||||
const NO_START: &str = "、。,.・:;?!゛゜ヽヾゝゞ々〻〉》」』】〕〗〙〛〞〟)]}⦆。」、・゙゚%¢";
|
||||
|
||||
/// East Asian characters a line may not end with: opening brackets and quotes, currency signs.
|
||||
const NO_END: &str = "〈《「『【〔〖〘〚〝([{⦅「$£¥";
|
||||
|
||||
/// Whether `c` is East Asian text: CJK ideographs, kana, bopomofo, CJK punctuation, and
|
||||
/// fullwidth/halfwidth forms (not spaces, not combining sound marks).
|
||||
pub fn is_east_asian(c: char) -> bool {
|
||||
matches!(c as u32,
|
||||
0x2E80..=0x2FFF // radicals, ideographic description
|
||||
| 0x3001..=0x303F // CJK symbols and punctuation (not the ideographic space)
|
||||
| 0x3040..=0x3098 // hiragana
|
||||
| 0x309B..=0x30FF // sound marks, katakana
|
||||
| 0x3100..=0x312F // bopomofo
|
||||
| 0x3190..=0x31FF // kanbun, CJK strokes, katakana extensions
|
||||
| 0x3400..=0x4DBF // CJK extension A
|
||||
| 0x4E00..=0x9FFF // CJK unified ideographs
|
||||
| 0xF900..=0xFAFF // compatibility ideographs
|
||||
| 0xFE30..=0xFE4F // CJK compatibility forms
|
||||
| 0xFF01..=0xFFEF // fullwidth and halfwidth forms
|
||||
| 0x20000..=0x3FFFF) // supplementary ideographs
|
||||
}
|
||||
|
||||
/// Adjust the break opportunities `opps` (byte offsets into `text`: a line may start there) for
|
||||
/// the paragraph's kinsoku and word-wrap settings. The result is sorted and has no duplicates.
|
||||
pub fn apply(text: &str, opps: &mut Vec<usize>, kinsoku: bool, word_wrap: bool) {
|
||||
let next_char = |i: usize| text.get(i..).and_then(|t| t.chars().next());
|
||||
let prev_char = |i: usize| text.get(..i).and_then(|t| t.chars().next_back());
|
||||
if kinsoku {
|
||||
opps.retain(|&i| !next_char(i).is_some_and(|c| NO_START.contains(c)) && !prev_char(i).is_some_and(|c| NO_END.contains(c)));
|
||||
} else {
|
||||
// Between any two East Asian characters.
|
||||
for (i, c) in text.char_indices().skip(1) {
|
||||
if is_east_asian(c) && prev_char(i).is_some_and(is_east_asian) {
|
||||
opps.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !word_wrap {
|
||||
// Between any two letters or digits that are not East Asian (whole graphemes, so accents
|
||||
// stay with their letters).
|
||||
let latin = |g: &str| g.chars().next().is_some_and(|c| c.is_alphanumeric() && !is_east_asian(c));
|
||||
let mut prev: Option<&str> = None;
|
||||
for (i, g) in text.grapheme_indices(true) {
|
||||
if prev.is_some_and(latin) && latin(g) {
|
||||
opps.push(i);
|
||||
}
|
||||
prev = Some(g);
|
||||
}
|
||||
}
|
||||
opps.sort_unstable();
|
||||
opps.dedup();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::apply;
|
||||
|
||||
/// Where a line may start, as the text from there to the next opportunity.
|
||||
fn breaks(text: &str, kinsoku: bool, word_wrap: bool) -> Vec<String> {
|
||||
let mut opps: Vec<usize> = unicode_linebreak::linebreaks(text).map(|(i, _)| i).filter(|&i| i < text.len()).collect();
|
||||
apply(text, &mut opps, kinsoku, word_wrap);
|
||||
opps.iter().map(|&i| text.get(i..).and_then(|t| t.chars().next()).map(String::from).unwrap_or_default()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinsoku_and_word_wrap_break_opportunities() {
|
||||
let ja = "日本語、「漢字」。テスト";
|
||||
let on = breaks(ja, true, true);
|
||||
for c in ["、", "」", "。", "漢"] {
|
||||
assert!(!on.iter().any(|s| s == c), "kinsoku: no line starts with {c}: {on:?}");
|
||||
}
|
||||
assert!(on.iter().any(|s| s == "「"), "a line may start at an opening bracket: {on:?}");
|
||||
assert!(on.iter().any(|s| s == "テ"), "{on:?}");
|
||||
let off = breaks(ja, false, true);
|
||||
for c in ["、", "」", "。", "漢"] {
|
||||
assert!(off.iter().any(|s| s == c), "kinsoku off: a line may start with {c}: {off:?}");
|
||||
}
|
||||
// Latin text: unchanged by kinsoku, broken anywhere without word wrap.
|
||||
let en = "Hello wonderful (world), 50%.";
|
||||
assert_eq!(breaks(en, true, true), breaks(en, false, true));
|
||||
assert_eq!(breaks(en, true, true), ["w", "(", "5"]);
|
||||
let any = breaks("Hello café", true, false);
|
||||
assert_eq!(any, ["e", "l", "l", "o", "c", "a", "f", "é"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
pub mod display;
|
||||
pub mod fields;
|
||||
pub mod hit;
|
||||
pub mod kinsoku;
|
||||
pub mod math;
|
||||
pub mod para;
|
||||
mod table;
|
||||
|
|
@ -1037,10 +1038,7 @@ struct PageFrame<'a> {
|
|||
|
||||
/// A floating (not inline) picture or shape: its size and placement.
|
||||
fn floating(o: &InlineObject) -> Option<(f32, f32, &Float)> {
|
||||
match o {
|
||||
InlineObject::Image { w, h, float, .. } | InlineObject::Shape { w, h, float, .. } if float.wrap != Wrap::Inline => Some((*w, *h, float)),
|
||||
_ => None,
|
||||
}
|
||||
o.frame().filter(|(_, _, float)| float.wrap != Wrap::Inline)
|
||||
}
|
||||
|
||||
/// Where a floating object goes, in the coordinates of the box `frame` places on the page.
|
||||
|
|
@ -1103,19 +1101,25 @@ fn rel_exclusions(excl: &[(Rect, bool)], x0: f32, y0: f32) -> Vec<para::Exclusio
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// The item drawing floating object `o` at `rect`.
|
||||
fn float_item(o: &InlineObject, rect: Rect, story: StoryRef, path: &[u32], off: usize) -> Option<Placed> {
|
||||
/// The items drawing floating object `o` at `rect` (a group: its members).
|
||||
fn float_items(o: &InlineObject, rect: Rect, story: StoryRef, path: &[u32], off: usize) -> Vec<Placed> {
|
||||
match o {
|
||||
InlineObject::Image { media, crop, .. } => {
|
||||
Some(Placed::Image { rect, media: media.clone(), crop: *crop, story, path: Path(path.to_vec()), off })
|
||||
vec![Placed::Image { rect, media: media.clone(), crop: *crop, story, path: Path(path.to_vec()), off }]
|
||||
}
|
||||
InlineObject::Shape { kind, fill, stroke, stroke_width, effects, .. } => {
|
||||
Some(Placed::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width, effects: *effects })
|
||||
vec![Placed::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width, effects: *effects }]
|
||||
}
|
||||
_ => None,
|
||||
InlineObject::Group { .. } => group_members(o, rect).into_iter().flat_map(|(r, c)| float_items(c, r, story, path, off)).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A group's members, each with its rectangle when the group is at `rect`.
|
||||
fn group_members(o: &InlineObject, rect: Rect) -> Vec<(Rect, &InlineObject)> {
|
||||
o.group_rects(rect.x, rect.y, rect.w, rect.h).into_iter().map(|([x, y, w, h], c)| (Rect::new(x, y, w, h), c)).collect()
|
||||
}
|
||||
|
||||
/// Position the floating objects anchored in `p` for a paragraph whose text starts at `y0` in
|
||||
/// the current column, add their wrap areas to the page (replacing any from an earlier attempt,
|
||||
/// from `excl_mark` on) and lay the paragraph out around them, with its list `label`.
|
||||
|
|
@ -1193,18 +1197,19 @@ fn place_objects(
|
|||
if left_out(oi) {
|
||||
continue;
|
||||
}
|
||||
let Some(obj @ (InlineObject::Image { w, h, float, .. } | InlineObject::Shape { w, h, float, .. })) = p.objects.get(oi) else { continue };
|
||||
let Some(obj) = p.objects.get(oi) else { continue };
|
||||
let Some((w, h, float)) = obj.frame() else { continue };
|
||||
let floating = float.wrap != Wrap::Inline;
|
||||
let rect = if floating {
|
||||
floats.get(&oi).copied().unwrap_or_else(|| float_rect(at.page, at.col, at.para_y, *w, *h, float))
|
||||
floats.get(&oi).copied().unwrap_or_else(|| float_rect(at.page, at.col, at.para_y, w, h, float))
|
||||
} else {
|
||||
let cx = x + line.cl_left(k).unwrap_or(0.0);
|
||||
display::inline_rect(Some(obj), cx, y + (line.baseline - fl.top), c.adv, c.obj_h)
|
||||
};
|
||||
let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front };
|
||||
// Floating ones are drawn here; inline ones with their line.
|
||||
if floating && let Some(it) = float_item(obj, rect, story, path, c.start) {
|
||||
layer.push(it);
|
||||
if floating {
|
||||
layer.extend(float_items(obj, rect, story, path, c.start));
|
||||
}
|
||||
let text_box = match obj {
|
||||
InlineObject::Shape { story: Some(id), .. } if ctx.doc.parts.get(id).is_some_and(|p| p.kind == wordcraft_doc::PartKind::TextBox) => {
|
||||
|
|
@ -1221,16 +1226,30 @@ fn place_objects(
|
|||
wrap: float.wrap,
|
||||
origin: Point::new(at.col.0, at.para_y),
|
||||
});
|
||||
// Its text, unless the box budget says no (a box inside itself, too deep, too many).
|
||||
let Some(id) = text_box.filter(|id| ctx.boxes.enter(*id)) else { continue };
|
||||
let blocks = ctx.doc.parts.get(&id).map(|p| p.blocks.clone()).unwrap_or_default();
|
||||
let (inner, _) = layout_box(ctx, StoryRef::Part(id), &blocks, &[], (rect.w - 2.0 * BOX_INSET_X).max(12.0), None, depth + 1, None);
|
||||
ctx.boxes.leave();
|
||||
let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front };
|
||||
// Text that doesn't fit inside the margins is hidden, as in Word.
|
||||
for mut it in fit_box(inner, rect.h - 2.0 * BOX_INSET_Y) {
|
||||
it.translate(rect.x + BOX_INSET_X, rect.y + BOX_INSET_Y);
|
||||
layer.push(it);
|
||||
// Its text (a group: its text boxes'), unless the box budget says no (a box inside
|
||||
// itself, too deep, too many).
|
||||
let boxes: Vec<(u32, Rect)> = match obj {
|
||||
InlineObject::Group { .. } => group_members(obj, rect)
|
||||
.into_iter()
|
||||
.filter_map(|(r, c)| {
|
||||
c.text_box().filter(|id| ctx.doc.parts.get(id).is_some_and(|p| p.kind == wordcraft_doc::PartKind::TextBox)).map(|id| (id, r))
|
||||
})
|
||||
.collect(),
|
||||
_ => text_box.map(|id| (id, rect)).into_iter().collect(),
|
||||
};
|
||||
for (id, rect) in boxes {
|
||||
if !ctx.boxes.enter(id) {
|
||||
continue;
|
||||
}
|
||||
let blocks = ctx.doc.parts.get(&id).map(|p| p.blocks.clone()).unwrap_or_default();
|
||||
let (inner, _) = layout_box(ctx, StoryRef::Part(id), &blocks, &[], (rect.w - 2.0 * BOX_INSET_X).max(12.0), None, depth + 1, None);
|
||||
ctx.boxes.leave();
|
||||
let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front };
|
||||
// Text that doesn't fit inside the margins is hidden, as in Word.
|
||||
for mut it in fit_box(inner, rect.h - 2.0 * BOX_INSET_Y) {
|
||||
it.translate(rect.x + BOX_INSET_X, rect.y + BOX_INSET_Y);
|
||||
layer.push(it);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1241,12 +1260,14 @@ fn place_objects(
|
|||
/// (on the box's cluster), as (cluster, note id).
|
||||
fn para_notes(doc: &Document, p: &Paragraph, pl: &ParaLayout) -> Vec<(usize, u32)> {
|
||||
let mut notes = pl.notes.clone();
|
||||
if p.objects.iter().any(|o| matches!(o, InlineObject::Shape { story: Some(_), .. })) {
|
||||
if p.objects.iter().any(|o| !o.text_boxes().is_empty()) {
|
||||
for (ci, c) in pl.clusters.iter().enumerate() {
|
||||
if let para::ClKind::Object(oi) = c.kind
|
||||
&& let Some(InlineObject::Shape { story: Some(id), .. }) = p.objects.get(oi)
|
||||
&& let Some(o) = p.objects.get(oi)
|
||||
{
|
||||
notes.extend(doc.notes_in_text_box(*id).into_iter().map(|n| (ci, n)));
|
||||
for id in o.text_boxes() {
|
||||
notes.extend(doc.notes_in_text_box(id).into_iter().map(|n| (ci, n)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1692,10 +1713,7 @@ fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &Secti
|
|||
|
||||
/// Is an object floating (not laid out inline)?
|
||||
pub fn is_floating(o: &InlineObject) -> bool {
|
||||
match o {
|
||||
InlineObject::Image { float, .. } | InlineObject::Shape { float, .. } => float.wrap != Wrap::Inline,
|
||||
_ => false,
|
||||
}
|
||||
o.is_floating()
|
||||
}
|
||||
|
||||
/// Milliseconds since an arbitrary epoch (monotonic on native; the browser's clock on the web).
|
||||
|
|
|
|||
|
|
@ -168,6 +168,14 @@ impl Line {
|
|||
None => self.xs.last().copied().unwrap_or(self.left),
|
||||
}
|
||||
}
|
||||
/// x of the line's visual end for the paragraph's direction, where its paragraph mark goes:
|
||||
/// the right edge of the rightmost cluster in a left-to-right paragraph, the left edge of the
|
||||
/// leftmost in a right-to-left one. Differs from [`Line::end_x`] when the logically last text
|
||||
/// runs the other way (Hebrew ending a left-to-right paragraph, Latin ending a right-to-left one).
|
||||
pub fn visual_end_x(&self) -> f32 {
|
||||
let end = self.end_x();
|
||||
if self.rtl { self.vis.iter().map(|v| v.x).fold(end, f32::min) } else { self.vis.iter().map(|v| v.x + v.w).fold(end, f32::max) }
|
||||
}
|
||||
/// x where the hyphen of a hyphenated line (advance `adv`) is drawn.
|
||||
pub fn hyphen_x(&self, adv: f32) -> f32 {
|
||||
match self.vis.last() {
|
||||
|
|
@ -248,8 +256,9 @@ pub struct ParaLayout {
|
|||
/// tracked deletions in the final text. Resolved as laid out, table formatting included.
|
||||
pub left_out: Vec<std::ops::Range<usize>>,
|
||||
/// The text drawn by clusters that stand for an object (field results, note numbers,
|
||||
/// equations) rather than for the paragraph's own text, by cluster index, sorted.
|
||||
pub shown: Vec<(usize, String)>,
|
||||
/// equations) rather than for the paragraph's own text, by cluster index, sorted, with each of
|
||||
/// the cluster's glyphs' byte range in that text (a ligature's glyph spans its letters).
|
||||
pub shown: Vec<(usize, String, Vec<std::ops::Range<usize>>)>,
|
||||
}
|
||||
|
||||
/// What a table style gives the text of a cell: its paragraph and run formatting, with the
|
||||
|
|
@ -302,7 +311,7 @@ struct Builder<'a> {
|
|||
/// Bidi level per byte of the paragraph text (empty: all left to right).
|
||||
levels: Vec<u8>,
|
||||
/// Text drawn by clusters that stand for an object, by cluster index (see `ParaLayout::shown`).
|
||||
shown: Vec<(usize, String)>,
|
||||
shown: Vec<(usize, String, Vec<std::ops::Range<usize>>)>,
|
||||
}
|
||||
|
||||
/// What a piece of text is shaped as: one face, case, script formatting and direction.
|
||||
|
|
@ -564,18 +573,28 @@ impl<'a> Builder<'a> {
|
|||
});
|
||||
return;
|
||||
};
|
||||
// Merge into one cluster: rebase glyph dx onto the first cluster.
|
||||
// Merge into one cluster: rebase glyph dx onto the first cluster, and keep each glyph's
|
||||
// text (a ligature's glyph takes the letters of the clusters after it that have none).
|
||||
let mut x = 0.0;
|
||||
let mut ranges: Vec<std::ops::Range<usize>> = Vec::new();
|
||||
for c in &added {
|
||||
for g in self.glyphs.get_mut(c.g0 as usize..c.g1 as usize).into_iter().flatten() {
|
||||
g.dx += x;
|
||||
}
|
||||
x += c.adv;
|
||||
let (a, b) = (c.start.saturating_sub(start), c.end.saturating_sub(start));
|
||||
if c.g1 <= c.g0 {
|
||||
let from = ranges.last().map(|r| r.start);
|
||||
for r in ranges.iter_mut().rev().take_while(|r| Some(r.start) == from) {
|
||||
r.end = b;
|
||||
}
|
||||
}
|
||||
ranges.extend((c.g0..c.g1).map(|_| a..b));
|
||||
}
|
||||
let g0 = first.g0;
|
||||
let style = first.style;
|
||||
let g1 = added.last().map(|c| c.g1).unwrap_or(g0);
|
||||
self.shown.push((self.clusters.len(), text.to_string()));
|
||||
self.shown.push((self.clusters.len(), text.to_string(), ranges));
|
||||
self.clusters.push(Cluster { start, end, adv: x, kind: ClKind::Text, style, g0, g1, break_after: false, obj_h: 0.0, obj_d: 0.0, dot: false });
|
||||
}
|
||||
}
|
||||
|
|
@ -713,8 +732,10 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
|||
let k = obj_index;
|
||||
obj_index += 1;
|
||||
match p.objects.get(k) {
|
||||
Some(InlineObject::Image { w, h, float, .. }) | Some(InlineObject::Shape { w, h, float, .. }) => {
|
||||
if float.wrap == wordcraft_doc::para::Wrap::Inline {
|
||||
Some(o) if o.is_drawing() => {
|
||||
if let Some((w, h, float)) = o.frame()
|
||||
&& float.wrap == wordcraft_doc::para::Wrap::Inline
|
||||
{
|
||||
let maxw = (env.width - rp.indent_left.max(0.0) - rp.indent_right.max(0.0)).max(18.0);
|
||||
let (w, h) = (w.clamp(1.0, 4000.0), h.clamp(1.0, 4000.0));
|
||||
let s = if w > maxw { maxw / w } else { 1.0 };
|
||||
|
|
@ -803,19 +824,19 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
|||
let to_para = |i: usize| -> Option<usize> {
|
||||
if left.is_empty() { Some(i) } else { ends.binary_search_by_key(&i, |e| e.0).ok().and_then(|k| ends.get(k)).map(|e| e.1) }
|
||||
};
|
||||
let mut opps = std::collections::HashSet::new();
|
||||
let mut found = Vec::new();
|
||||
for (i, o) in unicode_linebreak::linebreaks(&text) {
|
||||
// Word keeps "and/or" and web addresses whole: no break right after a slash (a word
|
||||
// too long for the line still breaks anywhere).
|
||||
let after_slash =
|
||||
text.get(..i).is_some_and(|t| t.ends_with('/')) && text.get(i..).and_then(|t| t.chars().next()).is_some_and(char::is_alphanumeric);
|
||||
if (o == unicode_linebreak::BreakOpportunity::Allowed || i < text.len())
|
||||
&& !after_slash
|
||||
&& let Some(end) = to_para(i)
|
||||
{
|
||||
opps.insert(end);
|
||||
if (o == unicode_linebreak::BreakOpportunity::Allowed || i < text.len()) && !after_slash {
|
||||
found.push(i);
|
||||
}
|
||||
}
|
||||
// Asian typography: kinsoku and breaking Latin words anywhere.
|
||||
crate::kinsoku::apply(&text, &mut found, rp.kinsoku, rp.word_wrap);
|
||||
let opps: std::collections::HashSet<usize> = found.into_iter().filter_map(to_para).collect();
|
||||
for c in &mut b.clusters {
|
||||
c.break_after = opps.contains(&c.end) || matches!(c.kind, ClKind::Object(_) | ClKind::Tab);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
use wordcraft_doc::numbering::Counters;
|
||||
use wordcraft_doc::props::{Align, Border, Borders, HeightRule, Rgb, TextDirection, VAlign, VMerge};
|
||||
use wordcraft_doc::styles::TableStyleProps;
|
||||
use wordcraft_doc::styles::{TableStyleParts, TableStyleProps};
|
||||
use wordcraft_doc::{StoryRef, Table};
|
||||
use wordcraft_geom::Rect;
|
||||
|
||||
|
|
@ -40,8 +40,9 @@ struct Turned {
|
|||
across: f32,
|
||||
/// List counters and equation count before the cell, to lay it out again the same way.
|
||||
snap: (Counters, u32),
|
||||
/// Its formatting region (header row, total row, first column) and cell index in the row.
|
||||
region: (bool, bool, bool),
|
||||
/// Its formatting region (header row, total row, first column, last column, row band, column
|
||||
/// band) and cell index in the row.
|
||||
region: (bool, bool, bool, bool, bool, bool),
|
||||
ci: usize,
|
||||
/// Top margin plus top border band.
|
||||
top: f32,
|
||||
|
|
@ -94,11 +95,54 @@ fn first_cell_left_margin_in(t: &Table, def: [f32; 4]) -> f32 {
|
|||
t.rows.first().and_then(|r| r.cells.first()).and_then(|c| c.props.margins).unwrap_or(def)[1]
|
||||
}
|
||||
|
||||
/// A cell's borders from the table style's conditional regions it is in (`regions`: header row,
|
||||
/// total row, first column, last column, row band, column band), later regions winning. Row
|
||||
/// regions give their left/right edges at the table's sides and `inside_v` between cells; column
|
||||
/// regions their top/bottom edges at the table's top and bottom and `between` between rows.
|
||||
fn region_borders(p: &TableStyleParts, (ri, nrows): (usize, usize), (first_g, last_g): (bool, bool), regions: [bool; 6]) -> Borders {
|
||||
let [header, total, first_col, last_col, band, col_band] = regions;
|
||||
let row = |b: Option<Borders>| {
|
||||
b.map(|b| Borders {
|
||||
top: b.top,
|
||||
bottom: b.bottom,
|
||||
left: if first_g { b.left } else { b.inside_v },
|
||||
right: if last_g { b.right } else { b.inside_v },
|
||||
between: None,
|
||||
inside_v: None,
|
||||
})
|
||||
};
|
||||
let col = |b: Option<Borders>| {
|
||||
b.map(|b| Borders {
|
||||
top: if ri == 0 { b.top } else { b.between },
|
||||
bottom: if ri + 1 >= nrows { b.bottom } else { b.between },
|
||||
left: b.left,
|
||||
right: b.right,
|
||||
between: None,
|
||||
inside_v: None,
|
||||
})
|
||||
};
|
||||
let mut out = Borders::default();
|
||||
for (on, b) in [
|
||||
(col_band, col(p.col_band_borders)),
|
||||
(band, row(p.band_borders)),
|
||||
(first_col, col(p.first_col_borders)),
|
||||
(last_col, col(p.last_col_borders)),
|
||||
(header, row(p.header_borders)),
|
||||
(total, row(p.total_borders)),
|
||||
] {
|
||||
if on && let Some(b) = b {
|
||||
out.overlay(&b);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], avail: f32, depth: usize) -> TableLayout {
|
||||
let style = table_style(ctx, t);
|
||||
let parts = style.as_ref().map(|s| &s.parts);
|
||||
// Cell text formatting per (header row, total row, first column) region, built once per table.
|
||||
let mut cell_text: Vec<((bool, bool, bool), CellText)> = Vec::new();
|
||||
// Cell text formatting per region (header row, total row, first column, last column, row
|
||||
// band, column band), built once per table.
|
||||
let mut cell_text: Vec<((bool, bool, bool, bool, bool, bool), CellText)> = Vec::new();
|
||||
let ncols = t.cols().max(1);
|
||||
// Column widths.
|
||||
let mut grid: Vec<f32> = if t.grid.len() == ncols {
|
||||
|
|
@ -152,6 +196,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
|||
};
|
||||
let nrows = t.rows.len();
|
||||
let header_rows = t.props.look.header_row;
|
||||
let band_size = parts.and_then(|p| p.band_size).unwrap_or(1).clamp(1, 1000) as usize;
|
||||
// First pass: lay out every cell's content.
|
||||
struct CellBox {
|
||||
items: Vec<Placed>,
|
||||
|
|
@ -178,7 +223,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
|||
let mut row_insets = 0.0f32;
|
||||
let is_header = header_rows && ri == 0;
|
||||
let is_total = t.props.look.total_row && ri + 1 == nrows && nrows > 1;
|
||||
let band = t.props.look.banded_rows && !is_header && (ri - usize::from(header_rows)) % 2 == 0;
|
||||
let band = t.props.look.banded_rows && !is_header && (ri.saturating_sub(usize::from(header_rows)) / band_size).is_multiple_of(2);
|
||||
for (ci, cell) in row.cells.iter().enumerate() {
|
||||
let span = cell.span();
|
||||
let x0 = colx.get(g).copied().unwrap_or(acc);
|
||||
|
|
@ -186,15 +231,34 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
|||
let margins = cell.props.margins.unwrap_or(margins_def);
|
||||
let cw = (x1 - x0 - margins[1] - margins[3]).max(4.0);
|
||||
let mut fill = cell.props.shading;
|
||||
let region = (is_header, is_total, t.props.look.first_column && g == 0);
|
||||
let first_col = t.props.look.first_column && g == 0;
|
||||
let last_col = t.props.look.last_column && g + span >= ncols && ncols > 1;
|
||||
let first_cols = usize::from(t.props.look.first_column);
|
||||
let col_band = t.props.look.banded_columns && !first_col && g.saturating_sub(first_cols).is_multiple_of(2);
|
||||
let region = (is_header, is_total, first_col, last_col, band, col_band);
|
||||
// Conditional formatting, later regions winning (ECMA-376 §17.7.6): whole table,
|
||||
// column bands, row bands, first/last column, header/total row.
|
||||
if let Some(p) = parts {
|
||||
if is_header {
|
||||
fill = fill.or(p.header_fill);
|
||||
}
|
||||
if band && fill.is_none() {
|
||||
fill = p.band_fill;
|
||||
}
|
||||
fill = fill.or(p.fill);
|
||||
let row_fill = if is_header {
|
||||
p.header_fill
|
||||
} else if is_total {
|
||||
p.total_fill
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let col_fill = if first_col {
|
||||
p.first_col_fill
|
||||
} else if last_col {
|
||||
p.last_col_fill
|
||||
} else {
|
||||
None
|
||||
};
|
||||
fill = fill
|
||||
.or(row_fill)
|
||||
.or(col_fill)
|
||||
.or(if band { p.band_fill } else { None })
|
||||
.or(if col_band { p.col_band_fill } else { None })
|
||||
.or(p.fill);
|
||||
}
|
||||
if let Some(st) = &style
|
||||
&& !cell_text.iter().any(|(r, _)| *r == region)
|
||||
|
|
@ -202,9 +266,18 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
|||
// The whole table's formatting, then column, then row conditional formatting
|
||||
// (later regions win, ECMA-376 §17.7.6).
|
||||
let mut chr = st.chr.clone();
|
||||
if region.2 {
|
||||
if col_band {
|
||||
chr.overlay(&st.parts.col_band_chr);
|
||||
}
|
||||
if band {
|
||||
chr.overlay(&st.parts.band_chr);
|
||||
}
|
||||
if first_col {
|
||||
chr.overlay(&st.parts.first_col_chr);
|
||||
}
|
||||
if last_col {
|
||||
chr.overlay(&st.parts.last_col_chr);
|
||||
}
|
||||
if is_header {
|
||||
chr.overlay(&st.parts.header_chr);
|
||||
} else if is_total {
|
||||
|
|
@ -221,15 +294,22 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
|||
let edge =
|
||||
|own: Option<Border>, outer: bool, outer_b: Option<Border>, inner_b: Option<Border>| own.or(if outer { outer_b } else { inner_b });
|
||||
let cb = cell.props.borders.unwrap_or_default();
|
||||
let (first_g, last_g) = (g == 0, g + span >= ncols);
|
||||
let rb = parts
|
||||
.map(|p| region_borders(p, (ri, nrows), (first_g, last_g), [is_header, is_total, first_col, last_col, band, col_band]))
|
||||
.unwrap_or_default();
|
||||
let mut borders = Borders {
|
||||
top: edge(cb.top, ri == 0, tb.top, tb.between),
|
||||
bottom: edge(cb.bottom, ri + 1 == nrows, tb.bottom, tb.between),
|
||||
left: edge(cb.left, g == 0, tb.left, tb.inside_v),
|
||||
right: edge(cb.right, g + span >= ncols, tb.right, tb.inside_v),
|
||||
top: edge(cb.top.or(rb.top), ri == 0, tb.top, tb.between),
|
||||
bottom: edge(cb.bottom.or(rb.bottom), ri + 1 == nrows, tb.bottom, tb.between),
|
||||
left: edge(cb.left.or(if first_g { rb.left } else { rb.inside_v }), first_g, tb.left, tb.inside_v),
|
||||
right: edge(cb.right.or(if last_g { rb.right } else { rb.inside_v }), last_g, tb.right, tb.inside_v),
|
||||
between: None,
|
||||
inside_v: None,
|
||||
};
|
||||
if is_total && let Some(b) = parts.and_then(|p| p.total_border_top) {
|
||||
if is_total
|
||||
&& parts.is_none_or(|p| p.total_borders.is_none_or(|b| b.top.is_none()))
|
||||
&& let Some(b) = parts.and_then(|p| p.total_border_top)
|
||||
{
|
||||
borders.top = Some(b);
|
||||
}
|
||||
// Word keeps the cell's text clear of its top border (and the last row's of its bottom
|
||||
|
|
|
|||
|
|
@ -261,10 +261,14 @@ fn many_hidden_float_anchors_lay_out_in_linear_time() {
|
|||
#[test]
|
||||
fn fragmented_hidden_text_lays_out_like_the_visible_text() {
|
||||
// Visible words with a hidden multi-byte char after every visible one, formatted in alternating
|
||||
// runs (bold on and off) so no two left-out runs are adjacent or merge.
|
||||
// runs (bold on and off) so no two left-out runs are adjacent or merge. The plain paragraph has
|
||||
// the same visible runs without the hidden chars, so both lay out the same widths whatever font
|
||||
// the default font resolves to.
|
||||
let words = "Hyphenation wraps international words across narrow columns of text ".repeat(6);
|
||||
let mut d = Document::from_text("");
|
||||
d.settings.auto_hyphenation = true;
|
||||
let mut plain = Document::from_text("");
|
||||
plain.settings.auto_hyphenation = true;
|
||||
let mut text = String::new();
|
||||
let mut kept = Vec::new();
|
||||
for (i, c) in words.chars().enumerate() {
|
||||
|
|
@ -277,10 +281,11 @@ fn fragmented_hidden_text_lays_out_like_the_visible_text() {
|
|||
d.insert_text(&Pos::body(0, n), &c.to_string(), &bold).unwrap();
|
||||
let hidden = wordcraft_doc::CharProps { hidden: Some(true), bold: Some(i % 2 == 1), ..Default::default() };
|
||||
d.insert_text(&Pos::body(0, at), "é", &hidden).unwrap();
|
||||
let n = plain.para(StoryRef::Body, &Path::top(0)).unwrap().len();
|
||||
plain.insert_text(&Pos::body(0, n), &c.to_string(), &bold).unwrap();
|
||||
}
|
||||
assert_eq!(d.para(StoryRef::Body, &Path::top(0)).unwrap().text, text);
|
||||
let mut plain = Document::from_text(&words);
|
||||
plain.settings.auto_hyphenation = true;
|
||||
assert_eq!(plain.para(StoryRef::Body, &Path::top(0)).unwrap().text, words);
|
||||
for doc in [&mut d, &mut plain] {
|
||||
doc.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.indent_right = Some(468.0 - 90.0)).unwrap();
|
||||
}
|
||||
|
|
@ -657,6 +662,20 @@ fn selection_rects_cover_range() {
|
|||
assert!(r[0].1.w > 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_segments_take_the_same_x_range_on_every_line() {
|
||||
let d = Document::from_text("abcdef\nabcdef\nab");
|
||||
let l = lay(&d);
|
||||
let left = l.caret(&Pos::body(0, 2)).unwrap().x;
|
||||
let right = l.caret(&Pos::body(0, 4)).unwrap().x;
|
||||
let segs = l.column_segments(&d, &Pos::body(2, 2), &Pos::body(0, 2), right, left, 0, 100);
|
||||
let offs: Vec<(u32, usize, usize)> = segs.iter().map(|(a, b)| (a.path.0[0], a.off, b.off)).collect();
|
||||
assert_eq!(offs, vec![(0, 2, 4), (1, 2, 4), (2, 2, 2)]);
|
||||
// Capped, and junk x gives nothing.
|
||||
assert_eq!(l.column_segments(&d, &Pos::body(0, 2), &Pos::body(2, 2), left, right, 0, 2).len(), 2);
|
||||
assert!(l.column_segments(&d, &Pos::body(0, 2), &Pos::body(2, 2), f32::NAN, right, 0, 100).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_has_glyphs_and_marks() {
|
||||
let mut d = Document::from_text("Hello\tworld");
|
||||
|
|
@ -685,6 +704,63 @@ fn a_tracked_paragraph_mark_shows_in_its_authors_colour() {
|
|||
assert_eq!(marks(false), [None, None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_breaks_show_with_formatting_marks() {
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
// Two sections: "One" ends the first; the second starts as `start`.
|
||||
let doc = |start: SectionStart| {
|
||||
let mut d = Document::from_text("One\nTwo");
|
||||
d.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().section = Some(Box::new(SectionProps::default()));
|
||||
d.last_section.start = start;
|
||||
d
|
||||
};
|
||||
let labels = |d: &Document, marks: bool| -> (Vec<String>, usize) {
|
||||
let l = lay(d);
|
||||
let items = display::page_display(d, &l.pages[0], &display::DisplayOptions { marks, ..Default::default() });
|
||||
let texts = items.iter().filter_map(|i| if let display::Draw::MarkText { text, .. } = i { Some(text.clone()) } else { None }).collect();
|
||||
let pilcrows = items.iter().filter(|i| matches!(i, display::Draw::Mark { ch: '¶', .. })).count();
|
||||
(texts, pilcrows)
|
||||
};
|
||||
let d = doc(SectionStart::Continuous);
|
||||
// The break replaces the first paragraph's ¶; the last paragraph keeps its own.
|
||||
assert_eq!(labels(&d, true), (vec!["Section Break (Continuous)".to_string()], 1));
|
||||
assert_eq!(labels(&d, false), (vec![], 0));
|
||||
assert_eq!(labels(&doc(SectionStart::NextPage), true).0, ["Section Break (Next Page)"]);
|
||||
assert_eq!(labels(&doc(SectionStart::EvenPage), true).0, ["Section Break (Even Page)"]);
|
||||
assert_eq!(labels(&doc(SectionStart::OddPage), true).0, ["Section Break (Odd Page)"]);
|
||||
// A document with one section has no break.
|
||||
assert_eq!(labels(&Document::from_text("One\nTwo"), true), (vec![], 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_marks_sit_past_text_running_the_other_way() {
|
||||
// A left-to-right paragraph ending in Hebrew, and a right-to-left one ending in Latin: the
|
||||
// logically last letters are at the wrong edge, but the ¶ still goes past every glyph.
|
||||
let mut d = Document::from_text("Hello \u{5e9}\u{5dc}\u{5d5}\u{5dd}\n\u{5e9}\u{5dc}\u{5d5}\u{5dd} abc");
|
||||
let at = Pos::body(1, 0);
|
||||
d.format_paragraphs(&at, &at, &|p: &mut ParaProps| p.bidi = Some(true)).unwrap();
|
||||
let l = lay(&d);
|
||||
// Leftmost and rightmost glyph edges (page x) of each paragraph's single line.
|
||||
let extents: Vec<(f32, f32)> = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| if let Placed::Lines { para, x, .. } = it { Some((para, *x)) } else { None })
|
||||
.map(|(pl, x)| {
|
||||
let ln = &pl.lines[0];
|
||||
let edges = (ln.c0..ln.c1).filter_map(|k| Some((ln.cl_left(k)?, ln.cl_right(k)?)));
|
||||
edges.fold((f32::MAX, f32::MIN), |(a, b), (l, r)| (a.min(x + l), b.max(x + r)))
|
||||
})
|
||||
.collect();
|
||||
let marks: Vec<(f32, f32)> = display::page_display(&d, &l.pages[0], &display::DisplayOptions { marks: true, ..Default::default() })
|
||||
.into_iter()
|
||||
.filter_map(|i| if let display::Draw::Mark { ch: '¶', x, size, .. } = i { Some((x, size)) } else { None })
|
||||
.collect();
|
||||
assert_eq!((extents.len(), marks.len()), (2, 2));
|
||||
let ((ltr_x, _), (rtl_x, size)) = (marks[0], marks[1]);
|
||||
assert!(ltr_x >= extents[0].1, "LTR ¶ at {ltr_x} should be right of all glyphs {:?}", extents[0]);
|
||||
assert!(rtl_x + size * 0.5 <= extents[1].0, "RTL ¶ at {rtl_x} should be left of all glyphs {:?}", extents[1]);
|
||||
}
|
||||
|
||||
fn border_lines(d: &Document) -> Vec<(f32, f32, f32, f32)> {
|
||||
border_lines_in(d, &lay(d))
|
||||
}
|
||||
|
|
@ -2325,3 +2401,25 @@ fn hidden_float_in_a_text_box_is_not_placed() {
|
|||
let (hidden_n, hidden_areas) = count(false);
|
||||
assert_eq!((shown - hidden_n, shown_areas - hidden_areas), (1, 1), "shown {shown}/{shown_areas}, hidden {hidden_n}/{hidden_areas}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinsoku_keeps_a_manual_line_break_before_a_closing_bracket() {
|
||||
// #269: kinsoku forbids a line starting with 」, but a manual line break (Shift+Enter) right
|
||||
// before one still ends the line there.
|
||||
let text = "日本語\n」テスト";
|
||||
let mut d = Document::from_text("");
|
||||
d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))];
|
||||
let l = lay(&d);
|
||||
let mut lines = Vec::new();
|
||||
for p in &l.pages {
|
||||
for it in &p.items {
|
||||
if let Placed::Lines { para, l0, l1, .. } = it {
|
||||
lines.extend(para.lines[*l0..*l1].iter().map(|line| (line.start, line.end)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let bracket = text.find('」').unwrap();
|
||||
assert_eq!(lines.len(), 2, "{lines:?}");
|
||||
assert_eq!(lines[0].1, LineEnd::LineBreak, "{lines:?}");
|
||||
assert_eq!(lines[1].0, bracket, "the second line starts at the bracket: {lines:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -303,24 +303,38 @@ fn arabic_face() -> Option<wordcraft_fonts::FaceRef> {
|
|||
wordcraft_fonts::FontDb::global().fallback_for('ب', 0).map(|f| wordcraft_fonts::FaceRef::of(&f)).filter(|f| f.covers('پ'))
|
||||
}
|
||||
|
||||
/// The glyph ids of each cluster, in order. A font may draw a letter as several glyphs (Noto
|
||||
/// Naskh Arabic draws ب as its dotless form plus the dot), so letters are compared by cluster.
|
||||
fn cluster_glyphs(glyphs: &[wordcraft_fonts::ShapedGlyph]) -> Vec<Vec<u32>> {
|
||||
let mut out: Vec<(usize, Vec<u32>)> = Vec::new();
|
||||
for g in glyphs {
|
||||
match out.last_mut() {
|
||||
Some((c, ids)) if *c == g.cluster => ids.push(g.gid),
|
||||
_ => out.push((g.cluster, vec![g.gid])),
|
||||
}
|
||||
}
|
||||
out.into_iter().map(|(_, ids)| ids).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persian_letters_join_and_brackets_mirror() {
|
||||
let Some(face) = arabic_face() else {
|
||||
eprintln!("skipped: no font with Persian letters installed");
|
||||
return;
|
||||
};
|
||||
let isolated = wordcraft_fonts::shape_run(&face, "ب", &[], |c| c, true);
|
||||
let joined = wordcraft_fonts::shape_run(&face, "ببب", &[], |c| c, true);
|
||||
assert_eq!(joined.len(), 3);
|
||||
assert!(joined.iter().all(|g| g.gid != 0), "the font has the letters");
|
||||
let isolated = cluster_glyphs(&wordcraft_fonts::shape_run(&face, "ب", &[], |c| c, true));
|
||||
assert_eq!(isolated.len(), 1, "{isolated:?}");
|
||||
let shaped = wordcraft_fonts::shape_run(&face, "ببب", &[], |c| c, true);
|
||||
let joined = cluster_glyphs(&shaped);
|
||||
assert_eq!(joined.len(), 3, "one cluster per letter: {joined:?}");
|
||||
assert!(shaped.iter().all(|g| g.gid != 0), "the font has the letters");
|
||||
// Initial, medial and final forms differ from the isolated letter.
|
||||
assert!(joined.iter().any(|g| g.gid != isolated[0].gid), "cursive joining picks contextual forms");
|
||||
assert!(joined.iter().any(|g| *g != isolated[0]), "cursive joining picks contextual forms: {joined:?} {isolated:?}");
|
||||
// Clusters come back in logical order.
|
||||
assert!(joined.windows(2).all(|w| w[0].cluster <= w[1].cluster));
|
||||
assert!(shaped.windows(2).all(|w| w[0].cluster <= w[1].cluster));
|
||||
// ZWNJ (نیمفاصله) breaks the join: "میخواهم" keeps می separate.
|
||||
let with_zwnj = wordcraft_fonts::shape_run(&face, "ب\u{200C}ب", &[], |c| c, true);
|
||||
let first = with_zwnj.first().map(|g| g.gid);
|
||||
assert_eq!(first, Some(isolated[0].gid), "a letter before ZWNJ takes its isolated form");
|
||||
let with_zwnj = cluster_glyphs(&wordcraft_fonts::shape_run(&face, "ب\u{200C}ب", &[], |c| c, true));
|
||||
assert_eq!(with_zwnj.first(), isolated.first(), "a letter before ZWNJ takes its isolated form");
|
||||
// In right-to-left text, "(" is drawn with the mirrored glyph.
|
||||
let open = wordcraft_fonts::shape_run(&face, "(", &[], |c| c, true);
|
||||
let close = wordcraft_fonts::shape_run(&face, ")", &[], |c| c, false);
|
||||
|
|
@ -354,6 +368,10 @@ fn glyph_runs_carry_their_text_for_pdf_export() {
|
|||
let s = "سلام WordCraft میخواهم";
|
||||
let d = doc_of(&[(s, true)]);
|
||||
let l = lay(&d);
|
||||
let lam_alef_ligature = arabic_face().is_some_and(|f| {
|
||||
let g = wordcraft_fonts::shape_run(&f, "لا", &[], |c| c, true);
|
||||
!g.is_empty() && g.iter().all(|g| g.cluster == 0)
|
||||
});
|
||||
let mut all = String::new();
|
||||
for it in display::page_display(&d, &l.pages[0], &Default::default()) {
|
||||
if let display::Draw::Glyphs { glyphs, text, ranges, .. } = it {
|
||||
|
|
@ -361,8 +379,9 @@ fn glyph_runs_carry_their_text_for_pdf_export() {
|
|||
for r in &ranges {
|
||||
assert!(text.get(r.clone()).is_some_and(|t| !t.is_empty()), "{r:?} in {text:?}");
|
||||
}
|
||||
// A ligature glyph (لا) shows both its letters (when a Persian font draws it).
|
||||
for r in ranges.iter().filter(|_| arabic_face().is_some()) {
|
||||
// A ligature glyph (لا) shows both its letters (when the Persian font draws one: some,
|
||||
// such as Noto Naskh Arabic, draw the lam and the alef as glyphs of their own).
|
||||
for r in ranges.iter().filter(|_| lam_alef_ligature) {
|
||||
if text.get(r.clone()) == Some("ل") {
|
||||
panic!("the lam of لا is drawn as one glyph with the alef: {text:?} {ranges:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -707,7 +707,7 @@ impl Exporter<'_> {
|
|||
s.set_fill(None);
|
||||
s.set_stroke(None);
|
||||
}
|
||||
Draw::Mark { .. } => {}
|
||||
Draw::Mark { .. } | Draw::MarkText { .. } => {}
|
||||
Draw::Turned { x, y, turn, items } => {
|
||||
if !ok(*x) || !ok(*y) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -539,6 +539,23 @@ fn field_results_are_extractable_text() {
|
|||
assert!(squash(&text).contains("See Section 3.01 for details."), "{text:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_result_ligatures_extract_all_their_letters() {
|
||||
// A field result is shaped as one cluster; a ligature in it (Carlito, the Calibri substitute,
|
||||
// joins "ti") used to get only its first letter, shifting the rest: "Sectio 3.01". Bundled
|
||||
// Source Sans 3 joins "fi" and "ffi", so this doesn't depend on the installed fonts.
|
||||
let font = CharProps { font: Some("Source Sans 3".into()), ..Default::default() };
|
||||
let result = "the first office";
|
||||
let face = wordcraft_fonts::FontDb::global().face("Source Sans 3", "Regular");
|
||||
assert!(wordcraft_fonts::shape(&face, result, &[], |c| c).len() < result.chars().count(), "the result has ligatures");
|
||||
let mut d = Document::new();
|
||||
let mut p = Paragraph::with_text("See here.", font.clone());
|
||||
p.insert_object(4, InlineObject::Field { instr: " REF _RefTarget \\h ".into(), result: result.into(), locked: false }, &font).unwrap();
|
||||
d.body = vec![para_block(p)];
|
||||
let text = extract_text(&export(&d, &PdfOptions::default()).unwrap()).concat();
|
||||
assert!(squash(&text).contains("See the first office here."), "{text:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_bold_text_is_extracted_once() {
|
||||
// JetBrains Mono ships without a bold face, so its bold is filled and stroked: the text
|
||||
|
|
|
|||
|
|
@ -382,6 +382,25 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
|
|||
ctx.set_transform(view * Affine::translate((*x as f64, *baseline as f64)) * Affine::scale(k));
|
||||
ctx.fill_path(&o);
|
||||
}
|
||||
Draw::MarkText { x, baseline, size, text } => {
|
||||
let base_face = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face;
|
||||
ctx.set_paint(color(opts.ink(opts.mark_color), 0.9));
|
||||
let mut gx = *x as f64;
|
||||
for ch in text.chars() {
|
||||
let mut face = base_face;
|
||||
if face.glyph_for(ch) == 0
|
||||
&& let Some(f) = FontDb::global().fallback_for(ch, face.id())
|
||||
{
|
||||
face = wordcraft_fonts::FaceRef::of(&f);
|
||||
}
|
||||
let gid = face.glyph_for(ch);
|
||||
let k = *size as f64 / face.upem.max(1.0);
|
||||
let o = FontDb::global().outline(&face, gid);
|
||||
ctx.set_transform(view * Affine::translate((gx, *baseline as f64)) * Affine::scale(k));
|
||||
ctx.fill_path(&o);
|
||||
gx += face.advance(gid) * k;
|
||||
}
|
||||
}
|
||||
Draw::Image { rect, media, crop, alpha } => {
|
||||
let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
|
||||
if !r.overlaps(*visible) {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ egui = { workspace = true }
|
|||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
# Pictures pasted from the system clipboard arrive as pixels and are inserted as PNG (#45).
|
||||
image = { workspace = true }
|
||||
# The system's preferred interface languages (#8). Native: OS APIs; wasm: none (English).
|
||||
sys-locale = "0.3"
|
||||
|
||||
|
|
@ -29,7 +31,6 @@ js-sys = "0.3"
|
|||
|
||||
[dev-dependencies]
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
image = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -270,17 +270,35 @@ fn info_page(app: &mut WordApp, ui: &mut Ui) {
|
|||
ui.columns(2, |cols| {
|
||||
let ui = &mut cols[0];
|
||||
ui.label(egui::RichText::new(tl!("Properties")).font(semibold(15.0)));
|
||||
// The fields edit a copy of the document's properties that is read afresh every frame,
|
||||
// so each change goes into the document as it is typed (#260): waiting for the field to
|
||||
// lose focus dropped the keystrokes, and leaving File with Escape never loses it at all.
|
||||
// The keystrokes of one visit to a field are a single undo step.
|
||||
let mut props = app.session.doc.core.clone();
|
||||
let mut changed = false;
|
||||
let mut edited = None;
|
||||
let mut focused = None;
|
||||
egui::Grid::new("props").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| {
|
||||
for (l, v) in [("Title", &mut props.title), ("Subject", &mut props.subject), ("Author", &mut props.creator), ("Keywords", &mut props.keywords), ("Category", &mut props.category)] {
|
||||
ui.label(tl!(l));
|
||||
changed |= ui.text_edit_singleline(v).lost_focus();
|
||||
let label = ui.label(tl!(l));
|
||||
let r = ui.add(egui::TextEdit::singleline(v).id_salt(("info-prop", l))).labelled_by(label.id);
|
||||
if r.changed() {
|
||||
edited = Some(l);
|
||||
}
|
||||
if r.has_focus() {
|
||||
focused = Some(l);
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
if changed {
|
||||
let _ = app.run("file.properties", json!({"title": props.title, "subject": props.subject, "author": props.creator, "keywords": props.keywords, "category": props.category}));
|
||||
if let Some(field) = edited {
|
||||
if app.info_editing == Some((field, app.session.rev())) {
|
||||
app.session.join_next_undo();
|
||||
}
|
||||
let ok = app.run("file.properties", json!({"title": props.title, "subject": props.subject, "author": props.creator, "keywords": props.keywords, "category": props.category})).is_ok();
|
||||
app.info_editing = ok.then(|| (field, app.session.rev()));
|
||||
}
|
||||
if focused != app.info_editing.map(|(f, _)| f) {
|
||||
app.info_editing = None;
|
||||
}
|
||||
let ui = &mut cols[1];
|
||||
ui.label(egui::RichText::new(tl!("Statistics")).font(semibold(15.0)));
|
||||
|
|
@ -445,4 +463,66 @@ mod tests {
|
|||
assert!(h.query_by_label("Report").is_some(), "New shows every template");
|
||||
assert!(h.query_by_label("Recent").is_none(), "New has no Recent list");
|
||||
}
|
||||
|
||||
/// #260: File › Info's Title and Author fields dropped every keystroke (they edited a copy
|
||||
/// re-read each frame and only wrote it back when the field lost focus), so a save had no
|
||||
/// title or author. Typing now lands in the document as it happens, one undo step per field,
|
||||
/// and the browser's Save downloads it with the save stamps advanced (#262).
|
||||
#[test]
|
||||
fn info_fields_keep_typed_properties_through_a_web_save() {
|
||||
use egui::accesskit::Role;
|
||||
let got: std::rc::Rc<std::cell::RefCell<Vec<(String, Vec<u8>)>>> = Default::default();
|
||||
let sink = got.clone();
|
||||
let services = Services {
|
||||
download: Some(Box::new(move |n: &str, b: &[u8]| {
|
||||
sink.borrow_mut().push((n.to_string(), b.to_vec()));
|
||||
Ok(())
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("XYZ")), services);
|
||||
a.session.author = "GAMMA".into();
|
||||
a.run("ui.backstage", json!({"value": true, "page": "info"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
h.run_steps(4);
|
||||
for (field, text) in [("Title", "ALPHA"), ("Author", "BETA")] {
|
||||
h.get_by_role_and_label(Role::TextInput, field).click();
|
||||
h.run_steps(2);
|
||||
for c in text.chars() {
|
||||
h.get_by_role_and_label(Role::TextInput, field).type_text(&c.to_string());
|
||||
h.run_steps(2);
|
||||
}
|
||||
assert_eq!(h.get_by_role_and_label(Role::TextInput, field).value().as_deref(), Some(text), "{field} shows what was typed");
|
||||
}
|
||||
let core = h.state().session.doc.core.clone();
|
||||
assert_eq!((core.title.as_str(), core.creator.as_str()), ("ALPHA", "BETA"));
|
||||
assert!(h.state().session.dirty, "the property edits are unsaved changes");
|
||||
|
||||
// Save (web: a download) writes them into docProps/core.xml, with fresh save stamps.
|
||||
let a = h.state_mut();
|
||||
let created = a.session.doc.core.created.clone();
|
||||
a.run("file.save", json!({})).unwrap();
|
||||
a.run("file.save", json!({})).unwrap();
|
||||
let (name, bytes) = got.borrow_mut().pop().unwrap();
|
||||
let back = wordcraft_engine::io::open_bytes(&name, &bytes).unwrap();
|
||||
assert_eq!((back.core.title.as_str(), back.core.creator.as_str()), ("ALPHA", "BETA"));
|
||||
assert_eq!(back.core.last_modified_by, "GAMMA");
|
||||
assert_eq!(back.core.revision, core.revision + 2, "each save advances the revision");
|
||||
assert!(created.is_empty() && !back.core.created.is_empty(), "the first save sets the creation time");
|
||||
assert!(!back.core.modified.is_empty());
|
||||
assert!(!a.session.dirty);
|
||||
|
||||
// Each field's typing is one undo step.
|
||||
a.session.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((a.session.doc.core.title.as_str(), a.session.doc.core.creator.as_str()), ("ALPHA", ""));
|
||||
a.session.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(a.session.doc.core.title, "");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ pub struct CanvasState {
|
|||
pub scroll_to_caret: bool,
|
||||
pub caret_visible_since: f64,
|
||||
dragging: bool,
|
||||
/// Alt+drag: where the column (block) selection started (page, x, y) and its last end.
|
||||
column_drag: Option<((usize, f32, f32), (usize, f32, f32))>,
|
||||
pub last_highlight: String,
|
||||
pub last_font_color: String,
|
||||
pub last_shading: String,
|
||||
|
|
@ -44,8 +46,15 @@ pub struct CanvasState {
|
|||
pub mini_anchor: Option<Rect>,
|
||||
/// A picture, shape or text box being dragged by its frame.
|
||||
pub(crate) obj_drag: Option<crate::objects::ObjectDrag>,
|
||||
/// Wheel/touchpad scrolling (smooth notches, touchpad momentum).
|
||||
pub(crate) wheel: crate::scroll::CanvasScroll,
|
||||
/// The scroll offset and its maximum at the end of last frame.
|
||||
pub(crate) scroll_offset: egui::Vec2,
|
||||
scroll_max: egui::Vec2,
|
||||
/// Pages per row last frame; when it changes the caret's page is scrolled back into view.
|
||||
pub cols: usize,
|
||||
/// A paste event arrived since the last Mod+V release (see `keys::canvas_events`).
|
||||
pub(crate) pasted: bool,
|
||||
}
|
||||
|
||||
impl CanvasState {
|
||||
|
|
@ -62,6 +71,7 @@ impl Default for CanvasState {
|
|||
scroll_to_caret: true,
|
||||
caret_visible_since: 0.0,
|
||||
dragging: false,
|
||||
column_drag: None,
|
||||
last_highlight: "yellow".into(),
|
||||
last_font_color: "C00000".into(),
|
||||
last_shading: "FFF2CC".into(),
|
||||
|
|
@ -78,6 +88,10 @@ impl Default for CanvasState {
|
|||
obj_drag: None,
|
||||
context_menu_open: false,
|
||||
mini_anchor: None,
|
||||
pasted: false,
|
||||
wheel: Default::default(),
|
||||
scroll_offset: egui::Vec2::ZERO,
|
||||
scroll_max: egui::Vec2::ZERO,
|
||||
cols: 1,
|
||||
}
|
||||
}
|
||||
|
|
@ -194,6 +208,18 @@ pub fn geometry(app: &WordApp, l: &DocLayout, avail: egui::Vec2) -> Geometry {
|
|||
Geometry { rects, size, scale, cols }
|
||||
}
|
||||
|
||||
/// While a fit mode (Page Width, One Page, Multiple Pages) sizes the page, keep the session's zoom
|
||||
/// equal to what is shown, as Word does. Zoom In/Out, the Zoom dialog and `view.state` then start
|
||||
/// from the visible zoom; before, they stepped from the stale manual zoom, so Zoom In from a 163%
|
||||
/// Page Width jumped to 110% (issue #67).
|
||||
pub fn sync_fit_zoom(app: &mut WordApp, scale: f32) {
|
||||
let v = &mut app.session.view;
|
||||
if v.fit.is_empty() || v.read_mode || v.mode != wordcraft_layout::ViewMode::Print || !scale.is_finite() {
|
||||
return;
|
||||
}
|
||||
v.zoom = (scale / PX_PER_PT).clamp(0.1, 5.0);
|
||||
}
|
||||
|
||||
/// Fingerprint of a page's content for the texture cache.
|
||||
fn page_key(app: &WordApp, page: &Page, scale_px: f32, dim_body: bool) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
|
|
@ -236,6 +262,43 @@ fn page_screen_scale(rect: Rect, page: &Page, fallback: f32) -> f32 {
|
|||
if page.w > 0.0 { rect.width() / page.w } else { fallback }
|
||||
}
|
||||
|
||||
/// Spacing of the View › Gridlines drawing grid: 0.5 cm, in points.
|
||||
const DRAWING_GRID_PT: f32 = 72.0 * 0.5 / 2.54;
|
||||
/// Closest the drawing grid's lines get on screen; zoomed far out, every other line is skipped.
|
||||
const DRAWING_GRID_MIN_PX: f32 = 4.0;
|
||||
/// Most grid lines drawn each way on one page (page sizes come from files).
|
||||
const DRAWING_GRID_MAX_LINES: usize = 2000;
|
||||
|
||||
/// The View › Gridlines drawing grid over a page's text area, in document points: the x of each
|
||||
/// vertical line and the y of each horizontal line, starting at the margins and `step` apart,
|
||||
/// never outside the margins. Drawn on screen only — never printed or exported.
|
||||
fn drawing_grid(body: wordcraft_geom::Rect, step: f32) -> (Vec<f32>, Vec<f32>) {
|
||||
let axis = |start: f32, len: f32| -> Vec<f32> {
|
||||
if !(step.is_finite() && step > 0.0 && start.is_finite() && len.is_finite() && len >= 0.0) {
|
||||
return Vec::new();
|
||||
}
|
||||
let end = start + len + 0.01;
|
||||
(0..DRAWING_GRID_MAX_LINES).map(|k| start + k as f32 * step).take_while(|p| *p <= end).collect()
|
||||
};
|
||||
(axis(body.x, body.w), axis(body.y, body.h))
|
||||
}
|
||||
|
||||
/// The drawing grid's spacing in points at a screen scale: the default 0.5 cm, doubled until the
|
||||
/// lines are at least [`DRAWING_GRID_MIN_PX`] apart on screen.
|
||||
fn drawing_grid_step(scale: f32) -> f32 {
|
||||
let mut step = DRAWING_GRID_PT;
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
return step;
|
||||
}
|
||||
for _ in 0..16 {
|
||||
if step * scale >= DRAWING_GRID_MIN_PX {
|
||||
break;
|
||||
}
|
||||
step *= 2.0;
|
||||
}
|
||||
step
|
||||
}
|
||||
|
||||
pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let layout = app.session.layout();
|
||||
|
|
@ -262,6 +325,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
}
|
||||
let geo = geometry(app, &layout, area.size() - vec2(14.0, 0.0));
|
||||
app.canvas.scale = geo.scale;
|
||||
sync_fit_zoom(app, geo.scale);
|
||||
// Zooming or resizing reflowed the pages into a different number of columns: the caret's page
|
||||
// moved, so bring it back into view rather than leave the reader somewhere else.
|
||||
if geo.cols != app.canvas.cols {
|
||||
|
|
@ -288,7 +352,28 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
app.canvas.scroll_to_caret = false;
|
||||
let mut origin = area.min;
|
||||
let mut ui_area = ui.new_child(egui::UiBuilder::new().max_rect(area));
|
||||
let out = egui::ScrollArea::both().id_salt("canvas_scroll").auto_shrink([false, false]).show_viewport(&mut ui_area, |ui, viewport| {
|
||||
// The canvas scrolls itself on wheel/touchpad input (crate::scroll): touchpads 1:1 with
|
||||
// momentum, wheel notches eased in. Applied before drawing, so it shows this frame.
|
||||
let hovered = ui.rect_contains_pointer(area) && ui.ctx().dragged_id().is_none();
|
||||
let notch = crate::scroll::notch_px(app.canvas.scale / PX_PER_PT);
|
||||
let opts = ui.ctx().options(|o| o.input_options);
|
||||
let delta = ui.input(|i| app.canvas.wheel.frame(i, &opts, hovered, notch, area.height()));
|
||||
let mut scroll_area = egui::ScrollArea::both()
|
||||
.id_salt("canvas_scroll")
|
||||
.auto_shrink([false, false])
|
||||
.scroll_source(egui::scroll_area::ScrollSource { mouse_wheel: false, ..Default::default() });
|
||||
if delta != egui::Vec2::ZERO {
|
||||
let before = app.canvas.scroll_offset;
|
||||
let after = (before - delta).clamp(egui::Vec2::ZERO, app.canvas.scroll_max);
|
||||
if after == before {
|
||||
app.canvas.wheel.hit_edge();
|
||||
}
|
||||
scroll_area = scroll_area.scroll_offset(after);
|
||||
}
|
||||
if app.canvas.wheel.is_animating() {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
let out = scroll_area.show_viewport(&mut ui_area, |ui, viewport| {
|
||||
origin = ui.min_rect().min - viewport.min.to_vec2();
|
||||
let content = Rect::from_min_size(ui.min_rect().min, geo.size);
|
||||
let resp = ui.allocate_rect(content, Sense::click_and_drag());
|
||||
|
|
@ -365,8 +450,23 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Table gridlines.
|
||||
if app.session.view.gridlines {
|
||||
// View › Gridlines: the drawing grid over the text area (Print Layout, on screen only).
|
||||
if app.session.view.gridlines && app.session.view.mode == wordcraft_layout::ViewMode::Print && !app.session.view.read_mode {
|
||||
let (xs, ys) = drawing_grid(page.body, drawing_grid_step(page_scale));
|
||||
let stroke = Stroke::new(1.0, t.blue.linear_multiply(0.22));
|
||||
let (x0, x1) = (visual_sr.min.x + page.body.x * page_scale, visual_sr.min.x + page.body.right() * page_scale);
|
||||
let (y0, y1) = (visual_sr.min.y + page.body.y * page_scale, visual_sr.min.y + page.body.bottom() * page_scale);
|
||||
for x in xs {
|
||||
let sx = visual_sr.min.x + x * page_scale;
|
||||
painter.line_segment([pos2(sx, y0), pos2(sx, y1)], stroke);
|
||||
}
|
||||
for y in ys {
|
||||
let sy = visual_sr.min.y + y * page_scale;
|
||||
painter.line_segment([pos2(x0, sy), pos2(x1, sy)], stroke);
|
||||
}
|
||||
}
|
||||
// Table Layout › View Gridlines: table cell outlines (on screen only).
|
||||
if app.session.view.table_gridlines {
|
||||
for it in &page.items {
|
||||
if let Placed::Cell { rect, .. } = it {
|
||||
let r = Rect::from_min_size(
|
||||
|
|
@ -386,18 +486,35 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
app.canvas.textures.retain(|k, _| *k < n);
|
||||
app.canvas.page_rects = rects.clone();
|
||||
balloons(app, ui, &painter, &rects, &layout, geo.scale);
|
||||
// Selection (a selected object shows its frame instead).
|
||||
if !app.session.sel.is_collapsed() && crate::objects::selected(app).is_none() {
|
||||
let (a, b) = app.session.sel.ordered();
|
||||
let mut first: Option<Rect> = None;
|
||||
for (pi, r) in layout.selection_rects(&app.session.doc, &a, &b, app.session.page_hint) {
|
||||
// Find › Reading Highlight.
|
||||
let marks: Vec<(Pos, Pos)> = app.session.find_highlights().iter().take(5000).cloned().collect();
|
||||
for (a, b) in &marks {
|
||||
for (pi, r) in layout.selection_rects(&app.session.doc, a, b, app.session.page_hint) {
|
||||
if let Some(pr) = rects.get(pi) {
|
||||
let scale = layout.pages.get(pi).map_or(geo.scale, |page| page_screen_scale(*pr, page, geo.scale));
|
||||
let sr = Rect::from_min_size(pos2(pr.min.x + r.x * scale, pr.min.y + r.y * scale), vec2(r.w * scale, r.h * scale));
|
||||
painter.rect_filled(sr, 0.0, t.selection);
|
||||
// Track the topmost rect: the mini toolbar anchors on the selection's first line.
|
||||
if first.is_none_or(|f| sr.min.y < f.min.y) {
|
||||
first = Some(sr);
|
||||
let hr = Rect::from_min_size(pos2(pr.min.x + r.x * scale, pr.min.y + r.y * scale), vec2(r.w * scale, r.h * scale));
|
||||
painter.rect_filled(hr, 0.0, t.find_highlight);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Selection (a selected object shows its frame instead).
|
||||
if !app.session.sel.is_collapsed() && crate::objects::selected(app).is_none() {
|
||||
// A column selection highlights each row's piece.
|
||||
let pieces: Vec<(Pos, Pos)> = match app.session.column_segments() {
|
||||
Some(segs) => segs.to_vec(),
|
||||
None => vec![app.session.sel.ordered()],
|
||||
};
|
||||
let mut first: Option<Rect> = None;
|
||||
for (a, b) in &pieces {
|
||||
for (pi, r) in layout.selection_rects(&app.session.doc, a, b, app.session.page_hint) {
|
||||
if let Some(pr) = rects.get(pi) {
|
||||
let scale = layout.pages.get(pi).map_or(geo.scale, |page| page_screen_scale(*pr, page, geo.scale));
|
||||
let sr = Rect::from_min_size(pos2(pr.min.x + r.x * scale, pr.min.y + r.y * scale), vec2(r.w * scale, r.h * scale));
|
||||
painter.rect_filled(sr, 0.0, t.selection);
|
||||
// Track the topmost rect: the mini toolbar anchors on the selection's first line.
|
||||
if first.is_none_or(|f| sr.min.y < f.min.y) {
|
||||
first = Some(sr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -491,6 +608,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
crate::objects::paint(app, &painter, &t, &layout, &rects, geo.scale);
|
||||
(resp, rects)
|
||||
});
|
||||
app.canvas.scroll_offset = out.state.offset;
|
||||
app.canvas.scroll_max = (out.content_size - out.inner_rect.size()).max(egui::Vec2::ZERO);
|
||||
let (resp, rects) = out.inner;
|
||||
// Focus: the canvas takes keyboard focus on click and keeps Tab/arrows.
|
||||
if resp.clicked() || resp.drag_started() || app.canvas.want_focus {
|
||||
|
|
@ -757,6 +876,15 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
|
|||
return;
|
||||
}
|
||||
let pj = serde_json::to_value(&pos).unwrap_or_default();
|
||||
// Alt+drag selects a column (block) of text.
|
||||
if mods.alt && !mods.shift && !mods.command {
|
||||
let _ = app.run("caret.set", json!({"pos": pj}));
|
||||
app.session.page_hint = page;
|
||||
app.canvas.column_drag = Some(((page, x, y), (page, x, y)));
|
||||
app.canvas.dragging = true;
|
||||
return;
|
||||
}
|
||||
app.canvas.column_drag = None;
|
||||
let _ = app.run("caret.set", json!({"pos": pj, "extend": mods.shift}));
|
||||
if mods.command && !mods.shift {
|
||||
let _ = app.run("select.sentence", json!({}));
|
||||
|
|
@ -764,6 +892,18 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
|
|||
app.session.page_hint = page;
|
||||
app.canvas.dragging = true;
|
||||
// Format painter applies on mouse up.
|
||||
} else if app.canvas.dragging
|
||||
&& ui.input(|i| i.pointer.primary_down())
|
||||
&& let Some((from, last)) = app.canvas.column_drag
|
||||
{
|
||||
if last != (page, x, y) {
|
||||
app.canvas.column_drag = Some((from, (page, x, y)));
|
||||
let story = app.session.sel.focus.story;
|
||||
let _ = app.run(
|
||||
"select.column",
|
||||
json!({"from": {"page": from.0, "x": from.1, "y": from.2}, "to": {"page": page, "x": x, "y": y}, "story": story}),
|
||||
);
|
||||
}
|
||||
} else if app.canvas.dragging && ui.input(|i| i.pointer.primary_down()) {
|
||||
if let Some(pos) = layout.hit(page, x, y, story)
|
||||
&& pos != app.session.sel.focus
|
||||
|
|
@ -775,6 +915,7 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
|
|||
// Auto-scroll near the edges is handled by egui's scroll area drag.
|
||||
} else if app.canvas.dragging && ui.input(|i| i.pointer.primary_released()) {
|
||||
app.canvas.dragging = false;
|
||||
app.canvas.column_drag = None;
|
||||
if app.session.painter.is_some() && !app.session.sel.is_collapsed() {
|
||||
let _ = app.run("edit.pasteFormat", json!({}));
|
||||
}
|
||||
|
|
@ -1240,6 +1381,47 @@ mod tests {
|
|||
assert_eq!(visual.size(), vec2(80.5, 120.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawing_grid_starts_at_the_margins_and_stays_inside_them() {
|
||||
// Letter page with 1" margins: the text area is 468 x 648 pt from (72, 72).
|
||||
let body = wordcraft_geom::Rect::new(72.0, 72.0, 468.0, 648.0);
|
||||
let (xs, ys) = drawing_grid(body, DRAWING_GRID_PT);
|
||||
assert_eq!(xs.first().copied(), Some(72.0));
|
||||
assert_eq!(ys.first().copied(), Some(72.0));
|
||||
assert!(xs.iter().all(|x| (72.0..=540.0).contains(x)), "{xs:?}");
|
||||
assert!(ys.iter().all(|y| (72.0..=720.0).contains(y)), "{ys:?}");
|
||||
// 0.5 cm apart, the same both ways: 468 pt holds 33 steps, 648 pt holds 45.
|
||||
assert_eq!(xs.len(), 34);
|
||||
assert_eq!(ys.len(), 46);
|
||||
for w in xs.windows(2).chain(ys.windows(2)) {
|
||||
assert!((w[1] - w[0] - DRAWING_GRID_PT).abs() < 0.01, "{w:?}");
|
||||
}
|
||||
// A grid line that lands on the margin is kept.
|
||||
let (xs, _) = drawing_grid(wordcraft_geom::Rect::new(0.0, 0.0, 100.0, 10.0), 25.0);
|
||||
assert_eq!(xs, vec![0.0, 25.0, 50.0, 75.0, 100.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawing_grid_survives_hostile_sizes() {
|
||||
let r = wordcraft_geom::Rect::new;
|
||||
for (body, step) in [
|
||||
(r(72.0, 72.0, 468.0, 648.0), 0.0),
|
||||
(r(72.0, 72.0, 468.0, 648.0), -5.0),
|
||||
(r(72.0, 72.0, 468.0, 648.0), f32::NAN),
|
||||
(wordcraft_geom::Rect { x: f32::NAN, y: 0.0, w: f32::INFINITY, h: -10.0 }, DRAWING_GRID_PT),
|
||||
] {
|
||||
let (xs, ys) = drawing_grid(body, step);
|
||||
assert!(xs.is_empty() && ys.is_empty(), "{body:?} {step}");
|
||||
}
|
||||
let (xs, ys) = drawing_grid(r(0.0, 0.0, 1.0e9, 1.0e9), DRAWING_GRID_PT);
|
||||
assert_eq!((xs.len(), ys.len()), (DRAWING_GRID_MAX_LINES, DRAWING_GRID_MAX_LINES));
|
||||
// Zoomed far out, lines thin out instead of filling the page.
|
||||
assert_eq!(drawing_grid_step(PX_PER_PT), DRAWING_GRID_PT);
|
||||
assert!(drawing_grid_step(0.1) * 0.1 >= DRAWING_GRID_MIN_PX);
|
||||
assert_eq!(drawing_grid_step(0.0), DRAWING_GRID_PT);
|
||||
assert_eq!(drawing_grid_step(f32::NAN), DRAWING_GRID_PT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texel_aligned_rect_keeps_integer_pixel_pages_unchanged() {
|
||||
let layout = Rect::from_min_size(pos2(10.0, 20.0), vec2(80.0, 120.0));
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
//! Dialogs: Font, Paragraph, Find & Replace, Go To, Insert Table, Page Setup, Link, Bookmark,
|
||||
//! Word Count, Zoom, Watermark, New/Modify Style, Command search, About, Save Changes, and the
|
||||
//! mail-merge Recipient List, Insert Merge Field, Find Recipient, merge rules, Match Fields and
|
||||
//! Check for Errors. Every dialog ends by running a command (or shows one's result), so agents get
|
||||
//! the same result without the dialog.
|
||||
//! Word Count, Zoom, Watermark, New/Modify Style, Manage Styles, New/Modify Table Style, Command
|
||||
//! search, Paste Special, About, Save Changes, and the mail-merge Recipient List, Insert Merge
|
||||
//! Field, Find Recipient, merge rules, Match Fields and Check for Errors. Every dialog ends by
|
||||
//! running a command (or shows one's result), so agents get the same result without the dialog.
|
||||
|
||||
use egui::{Sense, Ui, vec2};
|
||||
use serde::Serialize;
|
||||
|
|
@ -45,6 +45,15 @@ pub enum Dialog {
|
|||
keep_lines: bool,
|
||||
page_break: bool,
|
||||
widow: bool,
|
||||
/// Page: 0 Indents and Spacing, 1 Line and Page Breaks, 2 Asian Typography.
|
||||
tab: u8,
|
||||
/// Asian Typography as the model has it (`para.asianTypography`): kinsoku, word wrap,
|
||||
/// hanging punctuation, compress punctuation at line start, space between Asian and
|
||||
/// Latin text, space between Asian text and numbers.
|
||||
asian: [bool; 6],
|
||||
/// `asian` when the dialog opened: OK sets only the flags that changed.
|
||||
#[serde(skip)]
|
||||
asian_was: [bool; 6],
|
||||
},
|
||||
Find {
|
||||
query: String,
|
||||
|
|
@ -89,8 +98,13 @@ pub enum Dialog {
|
|||
NewStyle {
|
||||
name: String,
|
||||
based_on: String,
|
||||
/// Opened from Manage Styles, which comes back when this closes.
|
||||
#[serde(skip)]
|
||||
back: bool,
|
||||
},
|
||||
ModifyStyle {
|
||||
#[serde(skip)]
|
||||
back: bool,
|
||||
id: String,
|
||||
name: String,
|
||||
font: String,
|
||||
|
|
@ -101,9 +115,38 @@ pub enum Dialog {
|
|||
before: f32,
|
||||
after: f32,
|
||||
},
|
||||
/// Manage Styles: every style with its preview and description; modify, create, delete and
|
||||
/// show or hide them.
|
||||
ManageStyles {
|
||||
alphabetical: bool,
|
||||
selected: String,
|
||||
},
|
||||
/// Table Design › New / Modify Table Style: one formatting region at a time.
|
||||
TableStyle {
|
||||
/// The style being modified; `None` creates one.
|
||||
id: Option<String>,
|
||||
name: String,
|
||||
/// Style id.
|
||||
based_on: String,
|
||||
/// Index into `regions` (see [`REGION_LABELS`]).
|
||||
region: usize,
|
||||
regions: Box<[TableRegion; 7]>,
|
||||
/// What the regions showed when the dialog opened (or the base style changed): only
|
||||
/// changes are sent, so everything else stays inherited.
|
||||
#[serde(skip)]
|
||||
basis: Box<[TableRegion; 7]>,
|
||||
},
|
||||
Commands {
|
||||
query: String,
|
||||
},
|
||||
/// Paste Special: the clipboard's formats and the chosen one. The clipboard payload stays out
|
||||
/// of the serialized dialog state (it can be megabytes).
|
||||
PasteSpecial {
|
||||
formats: Vec<String>,
|
||||
choice: usize,
|
||||
#[serde(skip)]
|
||||
payload: Value,
|
||||
},
|
||||
/// Tabs: 0 About, 1 Contributors, 2 Models.
|
||||
About {
|
||||
tab: u8,
|
||||
|
|
@ -211,6 +254,215 @@ pub fn recipient_table(fields: &[String], rows: &[Vec<String>]) -> Result<Value,
|
|||
Ok(json!({"fields": fields, "rows": rows}))
|
||||
}
|
||||
|
||||
/// One table style region in the Table Style dialog: colours are hex, empty for none.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TableRegion {
|
||||
pub borders: bool,
|
||||
pub fill: String,
|
||||
pub bold: bool,
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
/// The Table Style dialog's regions, in the order of `table_style::REGIONS` (the param names).
|
||||
const REGION_LABELS: [&str; 7] = ["Whole Table", "Header Row", "Total Row", "First Column", "Last Column", "Banded Rows", "Banded Columns"];
|
||||
|
||||
/// The regions of table style `id` as resolved through its based-on chain.
|
||||
fn table_regions(app: &WordApp, id: &str) -> Box<[TableRegion; 7]> {
|
||||
let Some(st) = app.session.doc.styles.table_style(id) else { return Default::default() };
|
||||
let p = &st.parts;
|
||||
let hex = |c: Option<wordcraft_doc::Rgb>| c.map(|c| c.hex()).unwrap_or_default();
|
||||
let text = |c: &wordcraft_doc::CharProps| match c.color {
|
||||
Some(wordcraft_doc::TextColor::Rgb(c)) => c.hex(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let lines = |b: Option<wordcraft_doc::props::Borders>| b.is_some_and(|b| b.any_visible());
|
||||
// A region's text: the whole table's formatting with the region's over it.
|
||||
let region = |borders: Option<wordcraft_doc::props::Borders>, fill: Option<wordcraft_doc::Rgb>, chr: &wordcraft_doc::CharProps| {
|
||||
let mut c = st.chr.clone();
|
||||
c.overlay(chr);
|
||||
TableRegion { borders: lines(borders), fill: hex(fill), bold: c.bold.unwrap_or(false), color: text(&c) }
|
||||
};
|
||||
let none = wordcraft_doc::CharProps::default();
|
||||
Box::new([
|
||||
region(p.borders, p.fill, &none),
|
||||
region(p.header_borders, p.header_fill, &p.header_chr),
|
||||
region(p.total_borders, p.total_fill, &p.total_chr),
|
||||
region(p.first_col_borders, p.first_col_fill, &p.first_col_chr),
|
||||
region(p.last_col_borders, p.last_col_fill, &p.last_col_chr),
|
||||
region(p.band_borders, p.band_fill, &p.band_chr),
|
||||
region(p.col_band_borders, p.col_band_fill, &p.col_band_chr),
|
||||
])
|
||||
}
|
||||
|
||||
/// The params `table.newStyle` / `table.modifyStyle` take for the Table Style dialog's state:
|
||||
/// only what changed from `basis`, so the rest stays inherited.
|
||||
fn table_style_params(id: Option<&str>, name: &str, based_on: &str, regions: &[TableRegion; 7], basis: &[TableRegion; 7]) -> Value {
|
||||
let mut v = json!({"name": name.trim(), "basedOn": based_on});
|
||||
for ((key, r), b) in wordcraft_engine::cmd::table_style::REGIONS.into_iter().zip(regions.iter()).zip(basis.iter()) {
|
||||
let ch = region_changes(r, b);
|
||||
if ch.as_object().is_some_and(|o| !o.is_empty()) {
|
||||
v[key] = ch;
|
||||
}
|
||||
}
|
||||
if let Some(id) = id {
|
||||
v["style"] = json!(id);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// A small sample table drawn with `style` (5 columns, a header, three body rows and a total
|
||||
/// row), showing the regions `look` turns on: the Table Style dialog's live preview.
|
||||
fn table_style_preview(ui: &mut Ui, style: Option<&wordcraft_doc::styles::TableStyleProps>, look: wordcraft_doc::props::TableLook) {
|
||||
use wordcraft_doc::props::{Border, Borders};
|
||||
const COLS: usize = 5;
|
||||
const ROWS: usize = 5;
|
||||
const TEXT: [[&str; COLS]; ROWS] = [
|
||||
["", "Mon", "Tue", "Wed", "Sum"],
|
||||
["North", "4", "7", "2", "13"],
|
||||
["South", "6", "1", "5", "12"],
|
||||
["West", "3", "8", "4", "15"],
|
||||
["Total", "13", "16", "11", "40"],
|
||||
];
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(300.0, 120.0), Sense::hover());
|
||||
let painter = ui.painter_at(rect);
|
||||
// The page is white whatever the interface theme.
|
||||
painter.rect_filled(rect, 2.0, egui::Color32::WHITE);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
painter.rect_stroke(rect, 2.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
let Some(st) = style else { return };
|
||||
let p = &st.parts;
|
||||
let table = rect.shrink2(vec2(14.0, 10.0));
|
||||
let (cw, rh) = (table.width() / COLS as f32, table.height() / ROWS as f32);
|
||||
let rgb = |c: wordcraft_doc::Rgb| egui::Color32::from_rgb(c.0, c.1, c.2);
|
||||
let tb = p.borders.unwrap_or_default();
|
||||
for (ri, row) in TEXT.iter().enumerate() {
|
||||
let header = look.header_row && ri == 0;
|
||||
let total = look.total_row && ri + 1 == ROWS;
|
||||
let band = look.banded_rows
|
||||
&& !header
|
||||
&& (ri.saturating_sub(usize::from(look.header_row)) / p.band_size.unwrap_or(1).clamp(1, 1000) as usize).is_multiple_of(2);
|
||||
for (ci, txt) in row.iter().enumerate() {
|
||||
let first = look.first_column && ci == 0;
|
||||
let last = look.last_column && ci + 1 == COLS;
|
||||
let col_band = look.banded_columns && !first && ci.saturating_sub(usize::from(look.first_column)).is_multiple_of(2);
|
||||
let cell = egui::Rect::from_min_size(table.min + vec2(ci as f32 * cw, ri as f32 * rh), vec2(cw, rh));
|
||||
// Regions from lowest to highest priority, as layout applies them.
|
||||
let regions = [
|
||||
(col_band, p.col_band_fill, &p.col_band_chr, p.col_band_borders),
|
||||
(band, p.band_fill, &p.band_chr, p.band_borders),
|
||||
(first, p.first_col_fill, &p.first_col_chr, p.first_col_borders),
|
||||
(last, p.last_col_fill, &p.last_col_chr, p.last_col_borders),
|
||||
(header, p.header_fill, &p.header_chr, p.header_borders),
|
||||
(total, p.total_fill, &p.total_chr, p.total_borders),
|
||||
];
|
||||
let mut fill = p.fill;
|
||||
let mut chr = st.chr.clone();
|
||||
let mut b = Borders {
|
||||
top: if ri == 0 { tb.top } else { tb.between },
|
||||
bottom: if ri + 1 == ROWS { tb.bottom } else { tb.between },
|
||||
left: if ci == 0 { tb.left } else { tb.inside_v },
|
||||
right: if ci + 1 == COLS { tb.right } else { tb.inside_v },
|
||||
between: None,
|
||||
inside_v: None,
|
||||
};
|
||||
for (on, f, c, rb) in regions {
|
||||
if !on {
|
||||
continue;
|
||||
}
|
||||
fill = f.or(fill);
|
||||
chr.overlay(c);
|
||||
if let Some(rb) = rb {
|
||||
b.overlay(&Borders { between: None, inside_v: None, ..rb });
|
||||
}
|
||||
}
|
||||
if total && p.total_borders.is_none_or(|x| x.top.is_none()) {
|
||||
b.top = p.total_border_top.or(b.top);
|
||||
}
|
||||
if let Some(f) = fill {
|
||||
painter.rect_filled(cell, 0.0, rgb(f));
|
||||
}
|
||||
let edge = |from: egui::Pos2, to: egui::Pos2, e: Option<Border>| {
|
||||
if let Some(e) = e.filter(Border::is_visible) {
|
||||
let color = e.color.map_or(egui::Color32::BLACK, rgb);
|
||||
painter.line_segment([from, to], egui::Stroke::new(e.width.clamp(0.5, 3.0) * 1.3, color));
|
||||
}
|
||||
};
|
||||
edge(cell.left_top(), cell.right_top(), b.top);
|
||||
edge(cell.left_bottom(), cell.right_bottom(), b.bottom);
|
||||
edge(cell.left_top(), cell.left_bottom(), b.left);
|
||||
edge(cell.right_top(), cell.right_bottom(), b.right);
|
||||
let color = match chr.color {
|
||||
Some(wordcraft_doc::TextColor::Rgb(c)) => rgb(c),
|
||||
_ => egui::Color32::BLACK,
|
||||
};
|
||||
let font = egui::FontId::proportional(11.0);
|
||||
let pos = cell.left_center() + vec2(4.0, 0.0);
|
||||
painter.text(pos, egui::Align2::LEFT_CENTER, *txt, font.clone(), color);
|
||||
if chr.bold.unwrap_or(false) {
|
||||
// A second pass a hair to the right reads as bold at this size.
|
||||
painter.text(pos + vec2(0.6, 0.0), egui::Align2::LEFT_CENTER, *txt, font, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The style of the table at the caret, when it is a table style.
|
||||
fn current_table_style(app: &WordApp) -> Option<String> {
|
||||
let s = &app.session;
|
||||
let (tp, _, _) = s.sel.focus.path.cell()?;
|
||||
let id = s.doc.table(s.sel.focus.story, &tp)?.props.style.clone()?;
|
||||
s.doc.styles.get(&id).filter(|st| st.kind == wordcraft_doc::StyleKind::Table).map(|st| st.id.clone())
|
||||
}
|
||||
|
||||
/// The params `table.newStyle` / `table.modifyStyle` need for what changed in one region.
|
||||
fn region_changes(r: &TableRegion, basis: &TableRegion) -> Value {
|
||||
let color = |c: &str| if c.is_empty() { Value::Null } else { json!(c) };
|
||||
let mut v = json!({});
|
||||
if r.borders != basis.borders {
|
||||
v["borders"] = json!(r.borders);
|
||||
}
|
||||
if r.fill != basis.fill {
|
||||
v["fill"] = color(&r.fill);
|
||||
}
|
||||
if r.bold != basis.bold {
|
||||
v["bold"] = json!(r.bold);
|
||||
}
|
||||
if r.color != basis.color {
|
||||
v["color"] = color(&r.color);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// A colour menu: a swatch with the colour grid and No Color. `value` is hex, empty for none.
|
||||
fn color_menu(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String) {
|
||||
let c = wordcraft_doc::Rgb::parse(value);
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
|
||||
let t = Tokens::get(ui.ctx());
|
||||
match c {
|
||||
Some(c) => {
|
||||
ui.painter().rect_filled(r, 2.0, crate::theme::c32(c));
|
||||
}
|
||||
None => {
|
||||
ui.painter().line_segment([r.left_bottom(), r.right_top()], egui::Stroke::new(1.0, t.text_dim));
|
||||
}
|
||||
}
|
||||
ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside);
|
||||
let label = if c.is_some() { value.clone() } else { tl!("No Color").to_string() };
|
||||
ui.menu_button(label, |ui| {
|
||||
if ui.button(tl!("No Color")).clicked() {
|
||||
value.clear();
|
||||
ui.close();
|
||||
}
|
||||
if let Some(hex) = crate::widgets::color_grid(ui, theme) {
|
||||
*value = hex;
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
impl Dialog {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
|
|
@ -228,7 +480,11 @@ impl Dialog {
|
|||
Dialog::Watermark { .. } => "watermark",
|
||||
Dialog::NewStyle { .. } => "newStyle",
|
||||
Dialog::ModifyStyle { .. } => "modifyStyle",
|
||||
Dialog::ManageStyles { .. } => "manageStyles",
|
||||
Dialog::TableStyle { id: None, .. } => "newTableStyle",
|
||||
Dialog::TableStyle { .. } => "modifyTableStyle",
|
||||
Dialog::Commands { .. } => "commands",
|
||||
Dialog::PasteSpecial { .. } => "pasteSpecial",
|
||||
Dialog::About { .. } => "about",
|
||||
Dialog::SaveChanges { .. } => "saveChanges",
|
||||
Dialog::RecipientList { .. } => "recipientList",
|
||||
|
|
@ -277,7 +533,7 @@ impl Dialog {
|
|||
color: s("color"),
|
||||
spacing: 0.0,
|
||||
},
|
||||
"paragraph" => {
|
||||
"paragraph" | "asianTypography" => {
|
||||
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props));
|
||||
let rp = rp?;
|
||||
let line = match rp.line_spacing {
|
||||
|
|
@ -298,6 +554,9 @@ impl Dialog {
|
|||
keep_lines: rp.keep_lines,
|
||||
page_break: rp.page_break_before,
|
||||
widow: rp.widow_control,
|
||||
tab: if name == "asianTypography" { 2 } else { 0 },
|
||||
asian: [rp.kinsoku, rp.word_wrap, rp.overflow_punct, rp.top_line_punct, rp.auto_space_de, rp.auto_space_dn],
|
||||
asian_was: [rp.kinsoku, rp.word_wrap, rp.overflow_punct, rp.top_line_punct, rp.auto_space_de, rp.auto_space_dn],
|
||||
}
|
||||
}
|
||||
"find" | "replace" => Dialog::Find {
|
||||
|
|
@ -326,8 +585,24 @@ impl Dialog {
|
|||
"wordCount" => Dialog::WordCount { stats: app.session.run("review.wordCount", &json!({})).unwrap_or_default() },
|
||||
"zoom" => Dialog::Zoom { percent: (app.session.view.zoom * 100.0).round() },
|
||||
"watermark" => Dialog::Watermark { text: "CONFIDENTIAL".into(), diagonal: true },
|
||||
"newStyle" => Dialog::NewStyle { name: "Style1".into(), based_on: "Normal".into() },
|
||||
"newStyle" => Dialog::NewStyle { name: "Style1".into(), based_on: "Normal".into(), back: false },
|
||||
"manageStyles" => Dialog::ManageStyles { alphabetical: false, selected: s("style") },
|
||||
"newTableStyle" => {
|
||||
let based_on = current_table_style(app).unwrap_or_else(|| "TableGrid".into());
|
||||
let styles = &app.session.doc.styles;
|
||||
let name = (1..1000).map(|i| format!("Table Style {i}")).find(|n| styles.find(n).is_none()).unwrap_or_default();
|
||||
let regions = table_regions(app, &based_on);
|
||||
Dialog::TableStyle { id: None, name, based_on, region: 0, basis: regions.clone(), regions }
|
||||
}
|
||||
"modifyTableStyle" => {
|
||||
let id = current_table_style(app)?;
|
||||
let st = app.session.doc.styles.get(&id)?;
|
||||
let (name, based_on) = (st.name.clone(), st.based_on.clone().unwrap_or_default());
|
||||
let regions = table_regions(app, &id);
|
||||
Dialog::TableStyle { id: Some(id), name, based_on, region: 0, basis: regions.clone(), regions }
|
||||
}
|
||||
"commands" => Dialog::Commands { query: String::new() },
|
||||
"pasteSpecial" => Dialog::paste_special(app, &json!({})),
|
||||
"about" => Dialog::About { tab: 0 },
|
||||
"contributors" => Dialog::About { tab: 1 },
|
||||
"models" => Dialog::About { tab: 2 },
|
||||
|
|
@ -366,6 +641,18 @@ impl Dialog {
|
|||
})
|
||||
}
|
||||
|
||||
/// Paste Special for a clipboard payload (`text`, `html`, `rtf`; empty = WordCraft's own copy).
|
||||
pub fn paste_special(app: &WordApp, payload: &Value) -> Dialog {
|
||||
let formats: Vec<String> = wordcraft_engine::cmd::paste::available(&app.session, payload).into_iter().map(str::to_string).collect();
|
||||
let mut kept = json!({});
|
||||
for k in ["text", "html", "rtf"] {
|
||||
if let Some(t) = payload.get(k).and_then(Value::as_str) {
|
||||
kept[k] = json!(t);
|
||||
}
|
||||
}
|
||||
Dialog::PasteSpecial { formats, choice: 0, payload: kept }
|
||||
}
|
||||
|
||||
pub fn modify_style(app: &WordApp, id: &str) -> Option<Dialog> {
|
||||
let st = app.session.doc.styles.get(id)?;
|
||||
let rc = app.session.doc.styles.resolve_char(
|
||||
|
|
@ -377,6 +664,7 @@ impl Dialog {
|
|||
);
|
||||
let rp = app.session.doc.styles.resolve_para(&wordcraft_doc::ParaProps { style: Some(id.into()), ..Default::default() });
|
||||
Some(Dialog::ModifyStyle {
|
||||
back: false,
|
||||
id: id.into(),
|
||||
name: st.name.clone(),
|
||||
font: rc.font,
|
||||
|
|
@ -449,7 +737,11 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
|
|||
Dialog::Watermark { .. } => "Custom Watermark",
|
||||
Dialog::NewStyle { .. } => "Create New Style",
|
||||
Dialog::ModifyStyle { .. } => "Modify Style",
|
||||
Dialog::ManageStyles { .. } => "Manage Styles",
|
||||
Dialog::TableStyle { id: None, .. } => "New Table Style",
|
||||
Dialog::TableStyle { .. } => "Modify Table Style",
|
||||
Dialog::Commands { .. } => "Search Commands",
|
||||
Dialog::PasteSpecial { .. } => "Paste Special",
|
||||
Dialog::About { .. } => "About WordCraft",
|
||||
Dialog::SaveChanges { .. } => "WordCraft",
|
||||
Dialog::RecipientList { .. } => "Recipient List",
|
||||
|
|
@ -578,48 +870,94 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::Paragraph { rtl, align, left, right, first, before, after, line, keep_next, keep_lines, page_break, widow } => {
|
||||
ui.label(egui::RichText::new(tl!("General")).font(semibold(12.5)));
|
||||
Dialog::Paragraph {
|
||||
rtl,
|
||||
align,
|
||||
left,
|
||||
right,
|
||||
first,
|
||||
before,
|
||||
after,
|
||||
line,
|
||||
keep_next,
|
||||
keep_lines,
|
||||
page_break,
|
||||
widow,
|
||||
tab,
|
||||
asian,
|
||||
asian_was,
|
||||
} => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Direction:"));
|
||||
ui.radio_value(rtl, true, tl!("Right-to-left"));
|
||||
ui.radio_value(rtl, false, tl!("Left-to-right"));
|
||||
});
|
||||
egui::ComboBox::from_label(tl!("Alignment")).selected_text(align.clone()).show_ui(ui, |ui| {
|
||||
for a in ["left", "center", "right", "justify"] {
|
||||
ui.selectable_value(align, a.to_string(), a);
|
||||
for (k, name) in ["Indents and Spacing", "Line and Page Breaks", "Asian Typography"].into_iter().enumerate() {
|
||||
if ui.selectable_label(*tab as usize == k, tl!(name)).clicked() {
|
||||
*tab = k as u8;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Indentation")).font(semibold(12.5)));
|
||||
egui::Grid::new("ind").num_columns(4).show(ui, |ui| {
|
||||
ui.label(tl!("Left:"));
|
||||
ui.add(egui::DragValue::new(left).speed(0.05).suffix("\"").max_decimals(2));
|
||||
ui.label(tl!("Right:"));
|
||||
ui.add(egui::DragValue::new(right).speed(0.05).suffix("\"").max_decimals(2));
|
||||
ui.end_row();
|
||||
ui.label(tl!("First line:"));
|
||||
ui.add(egui::DragValue::new(first).speed(0.05).suffix("\"").max_decimals(2));
|
||||
ui.end_row();
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Spacing")).font(semibold(12.5)));
|
||||
egui::Grid::new("sp").num_columns(4).show(ui, |ui| {
|
||||
ui.label(tl!("Before:"));
|
||||
ui.add(egui::DragValue::new(before).speed(1.0).range(0.0..=1584.0).suffix(" pt"));
|
||||
ui.label(tl!("Line spacing:"));
|
||||
ui.add(egui::DragValue::new(line).speed(0.05).range(0.5..=5.0));
|
||||
ui.end_row();
|
||||
ui.label(tl!("After:"));
|
||||
ui.add(egui::DragValue::new(after).speed(1.0).range(0.0..=1584.0).suffix(" pt"));
|
||||
ui.end_row();
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Line and Page Breaks")).font(semibold(12.5)));
|
||||
ui.checkbox(widow, tl!("Widow/Orphan control"));
|
||||
ui.checkbox(keep_next, tl!("Keep with next"));
|
||||
ui.checkbox(keep_lines, tl!("Keep lines together"));
|
||||
ui.checkbox(page_break, tl!("Page break before"));
|
||||
ui.separator();
|
||||
match *tab {
|
||||
0 => {
|
||||
ui.label(egui::RichText::new(tl!("General")).font(semibold(12.5)));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Direction:"));
|
||||
ui.radio_value(rtl, true, tl!("Right-to-left"));
|
||||
ui.radio_value(rtl, false, tl!("Left-to-right"));
|
||||
});
|
||||
egui::ComboBox::from_label(tl!("Alignment")).selected_text(align.clone()).show_ui(ui, |ui| {
|
||||
for a in ["left", "center", "right", "justify"] {
|
||||
ui.selectable_value(align, a.to_string(), a);
|
||||
}
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Indentation")).font(semibold(12.5)));
|
||||
egui::Grid::new("ind").num_columns(4).show(ui, |ui| {
|
||||
ui.label(tl!("Left:"));
|
||||
ui.add(egui::DragValue::new(left).speed(0.05).suffix("\"").max_decimals(2));
|
||||
ui.label(tl!("Right:"));
|
||||
ui.add(egui::DragValue::new(right).speed(0.05).suffix("\"").max_decimals(2));
|
||||
ui.end_row();
|
||||
ui.label(tl!("First line:"));
|
||||
ui.add(egui::DragValue::new(first).speed(0.05).suffix("\"").max_decimals(2));
|
||||
ui.end_row();
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Spacing")).font(semibold(12.5)));
|
||||
egui::Grid::new("sp").num_columns(4).show(ui, |ui| {
|
||||
ui.label(tl!("Before:"));
|
||||
ui.add(egui::DragValue::new(before).speed(1.0).range(0.0..=1584.0).suffix(" pt"));
|
||||
ui.label(tl!("Line spacing:"));
|
||||
ui.add(egui::DragValue::new(line).speed(0.05).range(0.5..=5.0));
|
||||
ui.end_row();
|
||||
ui.label(tl!("After:"));
|
||||
ui.add(egui::DragValue::new(after).speed(1.0).range(0.0..=1584.0).suffix(" pt"));
|
||||
ui.end_row();
|
||||
});
|
||||
}
|
||||
1 => {
|
||||
ui.label(egui::RichText::new(tl!("Line and Page Breaks")).font(semibold(12.5)));
|
||||
ui.checkbox(widow, tl!("Widow/Orphan control"));
|
||||
ui.checkbox(keep_next, tl!("Keep with next"));
|
||||
ui.checkbox(keep_lines, tl!("Keep lines together"));
|
||||
ui.checkbox(page_break, tl!("Page break before"));
|
||||
}
|
||||
_ => {
|
||||
let [kinsoku, word_wrap, overflow, top_line, de, dn] = asian;
|
||||
ui.label(egui::RichText::new(tl!("Line breaking")).font(semibold(12.5)));
|
||||
ui.checkbox(kinsoku, tl!("Apply Asian line-breaking rules (kinsoku)"));
|
||||
// The model's flag is "wrap whole words"; the box offers the opposite.
|
||||
let mut mid_word = !*word_wrap;
|
||||
if ui.checkbox(&mut mid_word, tl!("Allow Latin text to wrap in the middle of a word")).changed() {
|
||||
*word_wrap = !mid_word;
|
||||
}
|
||||
ui.checkbox(overflow, tl!("Allow hanging punctuation"));
|
||||
ui.label(egui::RichText::new(tl!("Character Spacing")).font(semibold(12.5)));
|
||||
ui.checkbox(top_line, tl!("Compress punctuation at the start of a line"));
|
||||
ui.checkbox(de, tl!("Add space between Asian and Latin text"));
|
||||
ui.checkbox(dn, tl!("Add space between Asian text and numbers"));
|
||||
}
|
||||
}
|
||||
let (ok, cancel) = buttons(ui, tl!("OK"));
|
||||
if ok {
|
||||
apply_paragraph(app, *rtl, align, *left, *right, *first, *before, *after, *line, [*keep_next, *keep_lines, *page_break, *widow]);
|
||||
apply_asian(app, *asian, *asian_was);
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
|
|
@ -647,10 +985,24 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
ui.checkbox(whole_word, tl!("Whole words"));
|
||||
ui.checkbox(regex, tl!("Wildcards (regex)"));
|
||||
});
|
||||
let opts = json!({"text": query, "with": replace, "matchCase": *match_case, "wholeWord": *whole_word, "regex": *regex});
|
||||
if !*replace_mode {
|
||||
ui.horizontal(|ui| {
|
||||
let lit = app.session.find.highlight;
|
||||
if ui.selectable_label(lit, tl!("Reading Highlight")).clicked() {
|
||||
let mut v = opts.clone();
|
||||
v["highlight"] = json!(!lit);
|
||||
match app.run("edit.advancedFind", v) {
|
||||
Ok(r) if !lit => *message = crate::i18n::fmt(tl!("{count} items highlighted"), &[("count", &r["count"].to_string())]),
|
||||
Ok(_) => message.clear(),
|
||||
Err(e) => *message = e,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if !message.is_empty() {
|
||||
ui.label(egui::RichText::new(message.as_str()).weak());
|
||||
}
|
||||
let opts = json!({"text": query, "with": replace, "matchCase": *match_case, "wholeWord": *whole_word, "regex": *regex});
|
||||
let mut close = false;
|
||||
ui.horizontal(|ui| {
|
||||
if *replace_mode {
|
||||
|
|
@ -790,7 +1142,10 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
ui.radio_value(percent, p, format!("{p}%"));
|
||||
}
|
||||
});
|
||||
ui.add(egui::Slider::new(percent, 10.0..=500.0).suffix("%"));
|
||||
// Logarithmic like the status bar slider, and wide enough to aim: the default 100 pt
|
||||
// linear slider jumped from 100% to 480% within a short drag (issue #67).
|
||||
ui.spacing_mut().slider_width = 220.0;
|
||||
ui.add(egui::Slider::new(percent, 10.0..=500.0).logarithmic(true).step_by(1.0).suffix("%"));
|
||||
let mut fit = None;
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(tl!("Page width")).clicked() {
|
||||
|
|
@ -828,7 +1183,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::NewStyle { name, based_on } => {
|
||||
Dialog::NewStyle { name, based_on, back } => {
|
||||
egui::Grid::new("ns").num_columns(2).show(ui, |ui| {
|
||||
ui.label(tl!("Name:"));
|
||||
ui.text_edit_singleline(name);
|
||||
|
|
@ -839,12 +1194,17 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
});
|
||||
ui.label(egui::RichText::new(tl!("The new style takes the formatting of the current paragraph.")).small().weak());
|
||||
let (ok, cancel) = buttons(ui, tl!("OK"));
|
||||
let mut created = None;
|
||||
if ok {
|
||||
let _ = app.run("styles.create", json!({"name": name, "basedOn": based_on}));
|
||||
created =
|
||||
app.run("styles.create", json!({"name": name, "basedOn": based_on})).ok().and_then(|v| v.get("id")?.as_str().map(str::to_string));
|
||||
}
|
||||
if (ok || cancel) && *back {
|
||||
app.dialog = Some(Dialog::ManageStyles { alphabetical: false, selected: created.unwrap_or_default() });
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::ModifyStyle { id, name, font, size, bold, italic, color, before, after } => {
|
||||
Dialog::ModifyStyle { back, id, name, font, size, bold, italic, color, before, after } => {
|
||||
egui::Grid::new("ms").num_columns(2).show(ui, |ui| {
|
||||
ui.label(tl!("Name:"));
|
||||
ui.text_edit_singleline(name);
|
||||
|
|
@ -878,6 +1238,101 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
let _ =
|
||||
app.run("styles.modify", json!({"style": id, "name": name, "chr": chr, "para": {"spaceBefore": *before, "spaceAfter": *after}}));
|
||||
}
|
||||
if (ok || cancel) && *back {
|
||||
app.dialog = Some(Dialog::ManageStyles { alphabetical: false, selected: id.clone() });
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::ManageStyles { alphabetical, selected } => manage_styles(app, ui, alphabetical, selected),
|
||||
Dialog::TableStyle { id, name, based_on, region, regions, basis } => {
|
||||
let styles: Vec<(String, String)> = app
|
||||
.session
|
||||
.doc
|
||||
.styles
|
||||
.styles
|
||||
.iter()
|
||||
.filter(|s| s.kind == wordcraft_doc::StyleKind::Table && Some(&s.id) != id.as_ref())
|
||||
.map(|s| (s.id.clone(), s.name.clone()))
|
||||
.collect();
|
||||
let base_name = styles.iter().find(|(i, _)| i == based_on).map(|(_, n)| n.clone()).unwrap_or_else(|| based_on.clone());
|
||||
let mut rebased = false;
|
||||
egui::Grid::new("tstyle").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.label(tl!("Name:"));
|
||||
ui.text_edit_singleline(name);
|
||||
ui.end_row();
|
||||
ui.label(tl!("Style based on:"));
|
||||
egui::ComboBox::from_id_salt("tstyle_base").width(200.0).selected_text(base_name).show_ui(ui, |ui| {
|
||||
for (sid, sname) in &styles {
|
||||
if ui.selectable_label(sid == based_on, sname).clicked() && sid != based_on {
|
||||
*based_on = sid.clone();
|
||||
rebased = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label(tl!("Apply formatting to:"));
|
||||
egui::ComboBox::from_id_salt("tstyle_region")
|
||||
.width(200.0)
|
||||
.selected_text(tl!(REGION_LABELS.get(*region).copied().unwrap_or("Whole Table")))
|
||||
.show_ui(ui, |ui| {
|
||||
for (i, n) in REGION_LABELS.iter().enumerate() {
|
||||
ui.selectable_value(region, i, tl!(n));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
// A new style shows its new base's formatting; a modified one keeps its own.
|
||||
if rebased && id.is_none() {
|
||||
*regions = table_regions(app, based_on);
|
||||
*basis = regions.clone();
|
||||
}
|
||||
ui.separator();
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
if let Some(r) = regions.get_mut(*region) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.checkbox(&mut r.borders, tl!("Borders"));
|
||||
ui.checkbox(&mut r.bold, tl!("Bold"));
|
||||
});
|
||||
egui::Grid::new("tstyle_colors").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.label(tl!("Fill color:"));
|
||||
color_menu(ui, &theme, &mut r.fill);
|
||||
ui.end_row();
|
||||
ui.label(tl!("Font color:"));
|
||||
color_menu(ui, &theme, &mut r.color);
|
||||
ui.end_row();
|
||||
});
|
||||
}
|
||||
// Live preview: the style as OK would leave it, on the current table's options with
|
||||
// the region being edited turned on.
|
||||
let v = table_style_params(id.as_deref(), name, based_on, regions, basis);
|
||||
let mut look = app
|
||||
.session
|
||||
.sel
|
||||
.focus
|
||||
.path
|
||||
.cell()
|
||||
.and_then(|(tp, _, _)| app.session.doc.table(app.session.sel.focus.story, &tp).map(|t| t.props.look))
|
||||
.unwrap_or_default();
|
||||
match *region {
|
||||
1 => look.header_row = true,
|
||||
2 => look.total_row = true,
|
||||
3 => look.first_column = true,
|
||||
4 => look.last_column = true,
|
||||
5 => look.banded_rows = true,
|
||||
6 => look.banded_columns = true,
|
||||
_ => {}
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(tl!("Preview")).small().weak());
|
||||
table_style_preview(ui, wordcraft_engine::cmd::table_style::preview(&app.session, &v).as_ref(), look);
|
||||
let (ok, cancel) = buttons(ui, tl!("OK"));
|
||||
if ok {
|
||||
let cmd = if id.is_some() { "table.modifyStyle" } else { "table.newStyle" };
|
||||
if let Err(e) = app.run(cmd, v) {
|
||||
app.status(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::Commands { query } => {
|
||||
|
|
@ -909,6 +1364,34 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
}
|
||||
close || ui.input(|i| i.key_pressed(egui::Key::Escape))
|
||||
}
|
||||
Dialog::PasteSpecial { formats, choice, payload } => {
|
||||
ui.label(egui::RichText::new(tl!("Paste as:")).font(semibold(12.5)));
|
||||
if formats.is_empty() {
|
||||
ui.label(egui::RichText::new(tl!("The clipboard is empty.")).weak());
|
||||
}
|
||||
for (i, f) in formats.iter().enumerate() {
|
||||
let label = wordcraft_engine::cmd::paste::FORMATS.iter().find(|(id, _)| id == f).map(|(_, l)| *l).unwrap_or(f.as_str());
|
||||
ui.radio_value(choice, i, tl!(label));
|
||||
}
|
||||
if let Some(f) = formats.get(*choice) {
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new(tl!("Result")).font(semibold(12.5)));
|
||||
let hint = match f.as_str() {
|
||||
"formatted" => "Keeps the fonts and formatting of the copied text.",
|
||||
"rtf" => "Reads the clipboard's rich text and keeps its formatting.",
|
||||
"html" => "Reads the clipboard's web content and keeps its formatting.",
|
||||
_ => "Drops all formatting and pastes plain text.",
|
||||
};
|
||||
ui.label(egui::RichText::new(tl!(hint)).weak());
|
||||
}
|
||||
let (ok, cancel) = buttons(ui, tl!("OK"));
|
||||
if ok && let Some(f) = formats.get(*choice) {
|
||||
let mut params = payload.clone();
|
||||
params["as"] = json!(f);
|
||||
let _ = app.run("edit.pasteSpecial", params);
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::About { tab } => {
|
||||
ui.set_width(660.0);
|
||||
ui.horizontal(|ui| {
|
||||
|
|
@ -1217,6 +1700,175 @@ fn apply_paragraph(
|
|||
);
|
||||
}
|
||||
|
||||
/// The Paragraph dialog's Asian Typography: sets the flags that changed since it opened, so an
|
||||
/// untouched tab adds no direct formatting.
|
||||
fn apply_asian(app: &mut WordApp, asian: [bool; 6], was: [bool; 6]) {
|
||||
let keys = ["kinsoku", "wordWrap", "overflowPunct", "topLinePunct", "autoSpaceDE", "autoSpaceDN"];
|
||||
let changed: serde_json::Map<String, Value> =
|
||||
keys.iter().zip(asian.iter().zip(was)).filter(|(_, (now, was))| **now != *was).map(|(k, (now, _))| (k.to_string(), json!(now))).collect();
|
||||
if !changed.is_empty() {
|
||||
let _ = app.run("para.asianTypography", Value::Object(changed));
|
||||
}
|
||||
}
|
||||
|
||||
/// Manage Styles' body. Reads the list from `styles.manage`'s data (never re-running the command,
|
||||
/// which would ask to open this dialog again) and acts through commands.
|
||||
fn manage_styles(app: &mut WordApp, ui: &mut Ui, alphabetical: &mut bool, selected: &mut String) -> bool {
|
||||
let list = wordcraft_engine::cmd::para::manage_list(&app.session, *alphabetical);
|
||||
let list = list.as_array().cloned().unwrap_or_default();
|
||||
let str_of = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string();
|
||||
if !list.iter().any(|x| str_of(x, "id") == *selected) {
|
||||
*selected = list.first().map(|x| str_of(x, "id")).unwrap_or_default();
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Sort order:"));
|
||||
egui::ComboBox::from_id_salt("manage_styles_sort")
|
||||
.selected_text(if *alphabetical { tl!("Alphabetical") } else { tl!("As Recommended") })
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(alphabetical, false, tl!("As Recommended"));
|
||||
ui.selectable_value(alphabetical, true, tl!("Alphabetical"));
|
||||
});
|
||||
});
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let shown = egui::Id::new("manage_styles_shown");
|
||||
egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.border)).inner_margin(4).show(ui, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt("manage_styles_list").max_height(200.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.set_width(380.0);
|
||||
for st in &list {
|
||||
let id = str_of(st, "id");
|
||||
let glyph = match st.get("type").and_then(Value::as_str) {
|
||||
Some("character") => "a",
|
||||
Some("linked") => "¶a",
|
||||
Some("table") => "▦",
|
||||
_ => "¶",
|
||||
};
|
||||
let hidden = st.get("hidden").and_then(Value::as_bool).unwrap_or(false);
|
||||
let mut name = egui::RichText::new(format!("{glyph} {}", str_of(st, "name")));
|
||||
if hidden {
|
||||
name = name.weak();
|
||||
}
|
||||
let r = ui.selectable_label(*selected == id, name);
|
||||
// Bring the selection into view once, when it changes (opening, New Style…).
|
||||
if *selected == id && ui.data(|d| d.get_temp::<String>(shown)).as_deref() != Some(id.as_str()) {
|
||||
r.scroll_to_me(Some(egui::Align::Center));
|
||||
ui.data_mut(|d| d.insert_temp(shown, id.clone()));
|
||||
}
|
||||
if r.clicked() {
|
||||
ui.data_mut(|d| d.insert_temp(shown, id.clone()));
|
||||
*selected = id.clone();
|
||||
}
|
||||
if r.double_clicked() {
|
||||
*selected = id;
|
||||
if let Some(Dialog::ModifyStyle { id, name, font, size, bold, italic, color, before, after, .. }) =
|
||||
Dialog::modify_style(app, selected)
|
||||
{
|
||||
app.dialog = Some(Dialog::ModifyStyle { back: true, id, name, font, size, bold, italic, color, before, after });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
let leave = |ui: &mut Ui| {
|
||||
ui.data_mut(|d| d.remove::<String>(shown));
|
||||
true
|
||||
};
|
||||
if app.dialog.is_some() {
|
||||
return leave(ui);
|
||||
}
|
||||
let Some(cur) = list.iter().find(|x| str_of(x, "id") == *selected).cloned() else { return close_button(ui) && leave(ui) };
|
||||
let ty = str_of(&cur, "type");
|
||||
ui.add_space(6.0);
|
||||
crate::previews::style_preview(app, ui, selected, &ty, vec2(388.0, if ty == "table" { 64.0 } else { 40.0 }));
|
||||
ui.add_space(4.0);
|
||||
ui.add(egui::Label::new(egui::RichText::new(describe(&cur)).small()).wrap());
|
||||
ui.add_space(6.0);
|
||||
let builtin = cur.get("builtIn").and_then(Value::as_bool).unwrap_or(true);
|
||||
if ty != "table" {
|
||||
let mut gallery = cur.get("inGallery").and_then(Value::as_bool).unwrap_or(false);
|
||||
if ui.checkbox(&mut gallery, tl!("Show in the Styles gallery")).changed() {
|
||||
let _ = app.run("styles.setVisibility", json!({"style": selected, "gallery": gallery}));
|
||||
}
|
||||
}
|
||||
let mut hidden = cur.get("hidden").and_then(Value::as_bool).unwrap_or(false);
|
||||
if ui.checkbox(&mut hidden, tl!("Hide from style lists")).changed() {
|
||||
let _ = app.run("styles.setVisibility", json!({"style": selected, "hidden": hidden}));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
// Table styles are edited from Table Design.
|
||||
if ui.add_enabled(ty != "table", egui::Button::new(tl!("Modify…"))).clicked()
|
||||
&& let Some(Dialog::ModifyStyle { id, name, font, size, bold, italic, color, before, after, .. }) = Dialog::modify_style(app, selected)
|
||||
{
|
||||
app.dialog = Some(Dialog::ModifyStyle { back: true, id, name, font, size, bold, italic, color, before, after });
|
||||
}
|
||||
if ui.button(tl!("New Style…")).clicked() {
|
||||
app.dialog = Some(Dialog::NewStyle { name: "Style1".into(), based_on: str_of(&cur, "name"), back: true });
|
||||
}
|
||||
let del = ui.add_enabled(!builtin, egui::Button::new(tl!("Delete")));
|
||||
let del = if builtin { del.on_disabled_hover_text(tl!("Built-in styles can't be deleted.")) } else { del };
|
||||
if del.clicked() {
|
||||
let _ = app.run("styles.delete", json!({"style": selected}));
|
||||
selected.clear();
|
||||
}
|
||||
});
|
||||
(app.dialog.is_some() || close_button(ui)) && leave(ui)
|
||||
}
|
||||
|
||||
/// A dialog's single Close button (Escape too).
|
||||
fn close_button(ui: &mut Ui) -> bool {
|
||||
ui.add_space(8.0);
|
||||
let mut close = ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
close |= ui.add(egui::Button::new(egui::RichText::new(tl!("Close")).color(egui::Color32::WHITE)).fill(crate::theme::APP_COLOR)).clicked();
|
||||
});
|
||||
close
|
||||
}
|
||||
|
||||
/// A style's description: its type, the formatting it sets and what it is based on.
|
||||
fn describe(st: &Value) -> String {
|
||||
let f = st.get("format").cloned().unwrap_or_default();
|
||||
let fs = |k: &str| f.get(k).and_then(Value::as_str);
|
||||
let fnum = |k: &str| f.get(k).and_then(Value::as_f64);
|
||||
let pt = |x: f64| format!("{}", (x * 10.0).round() / 10.0);
|
||||
let mut parts: Vec<String> = vec![
|
||||
match st.get("type").and_then(Value::as_str) {
|
||||
Some("character") => tl!("Character style"),
|
||||
Some("linked") => tl!("Linked style (paragraph and character)"),
|
||||
Some("table") => tl!("Table style"),
|
||||
_ => tl!("Paragraph style"),
|
||||
}
|
||||
.to_string(),
|
||||
];
|
||||
if st.get("builtIn").and_then(Value::as_bool).unwrap_or(false) {
|
||||
parts.push(tl!("Built-in style").to_string());
|
||||
}
|
||||
if let Some(x) = fs("font") {
|
||||
parts.push(crate::i18n::fmt(tl!("Font: {font}"), &[("font", x)]));
|
||||
}
|
||||
if let Some(x) = fnum("size") {
|
||||
parts.push(crate::i18n::fmt(tl!("{size} pt"), &[("size", &pt(x))]));
|
||||
}
|
||||
if f.get("bold").and_then(Value::as_bool) == Some(true) {
|
||||
parts.push(tl!("Bold").to_string());
|
||||
}
|
||||
if f.get("italic").and_then(Value::as_bool) == Some(true) {
|
||||
parts.push(tl!("Italic").to_string());
|
||||
}
|
||||
if let Some(x) = fs("color") {
|
||||
parts.push(crate::i18n::fmt(tl!("Color: {color}"), &[("color", &format!("#{x}"))]));
|
||||
}
|
||||
if let Some(x) = fnum("spaceBefore") {
|
||||
parts.push(crate::i18n::fmt(tl!("Space before: {pt} pt"), &[("pt", &pt(x))]));
|
||||
}
|
||||
if let Some(x) = fnum("spaceAfter") {
|
||||
parts.push(crate::i18n::fmt(tl!("Space after: {pt} pt"), &[("pt", &pt(x))]));
|
||||
}
|
||||
if let Some(b) = st.get("basedOn").and_then(Value::as_str) {
|
||||
parts.push(crate::i18n::fmt(tl!("Based on: {style}"), &[("style", b)]));
|
||||
}
|
||||
parts.join(", ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -1235,7 +1887,7 @@ mod tests {
|
|||
|
||||
/// Open the Paragraph dialog and press OK without changing anything.
|
||||
fn ok_unchanged(app: &mut WordApp) {
|
||||
let Some(Dialog::Paragraph { rtl, align, left, right, first, before, after, line, keep_next, keep_lines, page_break, widow }) =
|
||||
let Some(Dialog::Paragraph { rtl, align, left, right, first, before, after, line, keep_next, keep_lines, page_break, widow, .. }) =
|
||||
Dialog::open("paragraph", app)
|
||||
else {
|
||||
panic!("no paragraph dialog")
|
||||
|
|
|
|||
|
|
@ -24,7 +24,10 @@
|
|||
Accept This Change Aceptar este cambio
|
||||
Accessibility Accesibilidad
|
||||
Add Añadir
|
||||
Add Object to Selection Añadir objeto a la selección
|
||||
Add Space Before Paragraph Añadir espacio antes del párrafo
|
||||
Add space between Asian and Latin text Añadir espacio entre texto asiático y latino
|
||||
Add space between Asian text and numbers Añadir espacio entre texto asiático y números
|
||||
Add Text Añadir texto
|
||||
Add a Table Añadir una tabla
|
||||
Add a comment Añadir un comentario
|
||||
|
|
@ -35,6 +38,8 @@
|
|||
Address\nBlock Bloque de\ndirección
|
||||
Adjust Ajustar
|
||||
Advanced Avanzado
|
||||
Advanced Find Búsqueda avanzada
|
||||
Advanced Find… Búsqueda avanzada…
|
||||
After: Después:
|
||||
Agents Agentes
|
||||
Align Alinear
|
||||
|
|
@ -45,13 +50,17 @@
|
|||
All Caps Mayúsculas
|
||||
All Markup Todas las marcas
|
||||
All caps Mayúsculas
|
||||
Allow hanging punctuation Permitir puntuación colgante
|
||||
Allow Latin text to wrap in the middle of a word Permitir que el texto latino se divida en mitad de una palabra
|
||||
Alt Text Texto alternativo
|
||||
Apply Asian line-breaking rules (kinsoku) Aplicar las reglas asiáticas de división de líneas (kinsoku)
|
||||
Apply Character Style Aplicar estilo de carácter
|
||||
Apply Style Aplicar estilo
|
||||
Arrange Organizar
|
||||
Arrange\nAll Organizar\ntodo
|
||||
Arrow Flecha
|
||||
Artistic Effects Efectos artísticos
|
||||
Asian Typography Tipografía asiática
|
||||
Author Autor
|
||||
AutoCorrect Options Opciones de autocorrección
|
||||
AutoFit Ajustar automáticamente
|
||||
|
|
@ -109,6 +118,7 @@
|
|||
Character Scale Escala del carácter
|
||||
Character Shading Sombreado de carácter
|
||||
Character Spacing Espaciado de caracteres
|
||||
Characters Caracteres
|
||||
Characters (no spaces) Caracteres (sin espacios)
|
||||
Characters (with spaces) Caracteres (con espacios)
|
||||
Chart Gráfico
|
||||
|
|
@ -120,8 +130,21 @@
|
|||
Citation Style Estilo de cita
|
||||
Citations & Bibliography Citas y bibliografía
|
||||
Clear Borrar
|
||||
Clear All Borrar todo
|
||||
Clear All Formatting Borrar todo el formato
|
||||
Clear Character Formatting Borrar formato de carácter
|
||||
Clear Character Style Borrar estilo de carácter
|
||||
Clear Formatting Level Borrar nivel de formato
|
||||
Clear Paragraph Formatting Borrar formato de párrafo
|
||||
Clipboard Portapapeles
|
||||
Clipboard Pane Panel del portapapeles
|
||||
Clipboard Items Elementos del portapapeles
|
||||
Paste Clipboard Item Pegar elemento del portapapeles
|
||||
Paste All Pegar todo
|
||||
Delete Clipboard Item Eliminar elemento del portapapeles
|
||||
Click an item to paste it. Haz clic en un elemento para pegarlo.
|
||||
Nothing collected yet. Items you copy or cut appear here. Aún no hay nada. Lo que copies o cortes aparecerá aquí.
|
||||
(picture or object) (imagen u objeto)
|
||||
Close Cerrar
|
||||
Close Header and Footer Cerrar encabezado y pie de página
|
||||
Collapse Selection Contraer selección
|
||||
|
|
@ -140,6 +163,7 @@
|
|||
Compact Compacto
|
||||
Compare Comparar
|
||||
Compress Pictures Comprimir imágenes
|
||||
Compress punctuation at the start of a line Comprimir la puntuación al inicio de la línea
|
||||
Continuous Continuo
|
||||
Convert Convertir
|
||||
Convert Text to Table Convertir texto en tabla
|
||||
|
|
@ -187,6 +211,7 @@
|
|||
Dictate Dictar
|
||||
Different First Page Primera página diferente
|
||||
Different Odd & Even Pages Páginas pares e impares diferentes
|
||||
Direct formatting Formato directo
|
||||
Discord Discord
|
||||
Discord community Comunidad de Discord
|
||||
Display Mostrar
|
||||
|
|
@ -214,6 +239,7 @@
|
|||
Drawing Tools Herramientas de dibujo
|
||||
Drawing\nCanvas Lienzo de\ndibujo
|
||||
Drop Cap Letra capital
|
||||
Drops all formatting and pastes plain text. Quita todo el formato y pega texto sin formato.
|
||||
Edit Footer Editar pie de página
|
||||
Edit Header Editar encabezado
|
||||
Edit Recipient List Editar lista de destinatarios
|
||||
|
|
@ -269,11 +295,13 @@
|
|||
Format Page Numbers Formato de números de página
|
||||
Format Page Numbers… Formato de números de página…
|
||||
Format Painter Pincel de formato
|
||||
Formatted Text Texto con formato
|
||||
Formatting at Selection Formato en la selección
|
||||
Formula Fórmula
|
||||
General General
|
||||
Go To Ir a
|
||||
Go To (⌘⌥G) Ir a (⌘⌥G)
|
||||
Go To… Ir a…
|
||||
Go to Ir a
|
||||
Grab bag Bolsa de datos adicionales
|
||||
Greeting Line Línea de saludo
|
||||
|
|
@ -281,6 +309,7 @@
|
|||
Gridlines Líneas de cuadrícula
|
||||
Group Agrupar
|
||||
Grow Font 1 Point Aumentar fuente 1 punto
|
||||
HTML Format Formato HTML
|
||||
Hanging Indent Sangría francesa
|
||||
Header Encabezado
|
||||
Header & Footer Encabezado y pie de página
|
||||
|
|
@ -311,6 +340,7 @@
|
|||
Indent Sangría
|
||||
Indentation Sangría
|
||||
Indents Sangrías
|
||||
Indents and Spacing Sangría y espaciado
|
||||
Index Índice
|
||||
Info Información
|
||||
Ink to\nMath Tinta a\nfórmula
|
||||
|
|
@ -340,6 +370,7 @@
|
|||
Insert\nFootnote Insertar nota\nal pie
|
||||
Inside Borders Bordes interiores
|
||||
Inspect Document Inspeccionar documento
|
||||
Inspect Styles at Selection Inspeccionar estilos de la selección
|
||||
Interface language: Idioma de la interfaz:
|
||||
Italic Cursiva
|
||||
Join the ArtCraft community on Discord Únete a la comunidad de ArtCraft en Discord
|
||||
|
|
@ -350,6 +381,7 @@
|
|||
Keep lines together Mantener líneas juntas
|
||||
Keep with Next Mantener con el siguiente
|
||||
Keep with next Mantener con el siguiente
|
||||
Keeps the fonts and formatting of the copied text. Conserva las fuentes y el formato del texto copiado.
|
||||
Labels Etiquetas
|
||||
Landscape Horizontal
|
||||
Language Idioma
|
||||
|
|
@ -364,6 +396,7 @@
|
|||
Letter Carta
|
||||
Line Línea
|
||||
Line Break Salto de línea
|
||||
Line breaking División de líneas
|
||||
Line End Fin de línea
|
||||
Line Numbers Números de línea
|
||||
Line Spacing Interlineado
|
||||
|
|
@ -494,6 +527,9 @@
|
|||
Paste Pegar
|
||||
Paste (⌘V) Pegar (⌘V)
|
||||
Paste Formatting Pegar formato
|
||||
Paste Special Pegado especial
|
||||
Paste Special… Pegado especial…
|
||||
Paste as: Pegar como:
|
||||
Paste: Keep Text Only Pegar: mantener solo el texto
|
||||
Paste: Merge Formatting Pegar: combinar formato
|
||||
Pen Pluma
|
||||
|
|
@ -527,6 +563,9 @@
|
|||
Read Mode Modo lectura
|
||||
Read\nAloud Leer en\nvoz alta
|
||||
Read\nMode Modo\nlectura
|
||||
Reads the clipboard's rich text and keeps its formatting. Lee el texto enriquecido del portapapeles y conserva su formato.
|
||||
Reads the clipboard's web content and keeps its formatting. Lee el contenido web del portapapeles y conserva su formato.
|
||||
Reading Highlight Resaltado de lectura
|
||||
Recent Recientes
|
||||
Record Macro Grabar macro
|
||||
Recover Unsaved Documents Recuperar documentos sin guardar
|
||||
|
|
@ -557,6 +596,7 @@
|
|||
Research Investigación
|
||||
Researcher Investigador
|
||||
Reset Picture Restablecer imagen
|
||||
Reset to Normal Restablecer a Normal
|
||||
Resolve Resolver
|
||||
Resolved Resuelto
|
||||
Restart Each Page Reiniciar en cada página
|
||||
|
|
@ -564,12 +604,14 @@
|
|||
Restart at 1 Reiniciar en 1
|
||||
Restrict Editing Restringir edición
|
||||
Restrict\nEditing Restringir\nedición
|
||||
Result Resultado
|
||||
Results Resultados
|
||||
Reverse the order Invertir el orden
|
||||
Reviewing Revisión
|
||||
Reviewing Pane Panel de revisión
|
||||
Reviewing — edits become suggestions Revisión — las ediciones pasan a ser sugerencias
|
||||
Rich Text Format (*.rtf) Formato de texto enriquecido (*.rtf)
|
||||
Rich Text Format (RTF) Formato de texto enriquecido (RTF)
|
||||
Right Derecha
|
||||
Right Border Borde derecho
|
||||
Right-to-Left Text Direction Dirección de texto de derecha a izquierda
|
||||
|
|
@ -669,6 +711,7 @@
|
|||
Studio cover Portada de Studio
|
||||
Studio handbook (sample) Manual de Studio (ejemplo)
|
||||
Style Estilo
|
||||
Style Inspector Inspector de estilos
|
||||
Style Set Conjunto de estilos
|
||||
Style based on: Estilo basado en:
|
||||
Style: APA Estilo: APA
|
||||
|
|
@ -708,6 +751,7 @@
|
|||
Text: Texto:
|
||||
Text\nBox Cuadro de\ntexto
|
||||
Text\nDirection Dirección del\ntexto
|
||||
The clipboard is empty. El portapapeles está vacío.
|
||||
The new style takes the formatting of the current paragraph. El nuevo estilo toma el formato del párrafo actual.
|
||||
Theme Colors Colores del tema
|
||||
Theme Fonts Fuentes del tema
|
||||
|
|
@ -730,11 +774,13 @@
|
|||
Two Dos
|
||||
Type Text Escribir texto
|
||||
Type a command, e.g. "insert table" Escribe un comando, p. ej. "insertar tabla"
|
||||
Ungroup Desagrupar
|
||||
UPPERCASE MAYÚSCULAS
|
||||
Underline Subrayado
|
||||
Underline Words Only Subrayar solo las palabras
|
||||
Undo Deshacer
|
||||
Undo {action} Deshacer {action}
|
||||
Unformatted Text Texto sin formato
|
||||
Up Arriba
|
||||
Update Field Actualizar campo
|
||||
Update Index Actualizar índice
|
||||
|
|
@ -895,6 +941,42 @@
|
|||
Opening print dialog… Abriendo el cuadro de diálogo de impresión…
|
||||
Print failed: {error} Error al imprimir: {error}
|
||||
Couldn't open {path}: {error} No se pudo abrir {path}: {error}
|
||||
Column Selection Selección de columna
|
||||
New Table Style Nuevo estilo de tabla
|
||||
Modify Table Style Modificar estilo de tabla
|
||||
New Table Style… Nuevo estilo de tabla…
|
||||
Modify Table Style… Modificar estilo de tabla…
|
||||
Apply formatting to: Aplicar formato a:
|
||||
Whole Table Toda la tabla
|
||||
Fill color: Color de relleno:
|
||||
{count} items highlighted {count} elementos resaltados
|
||||
{n} pt {n} pt
|
||||
Not {name} Sin {name}
|
||||
Level {n} Nivel {n}
|
||||
Body Text Texto independiente
|
||||
{n} lines {n} líneas
|
||||
At least {n} Mínimo {n}
|
||||
Exactly {n} Exacto {n}
|
||||
Centered Centrado
|
||||
Character spacing Espaciado de caracteres
|
||||
Don't add space between paragraphs of the same style No agregar espacio entre párrafos del mismo estilo
|
||||
Don't hyphenate No separar con guiones
|
||||
Double strikethrough Doble tachado
|
||||
Drop cap Letra capital
|
||||
First line Primera línea
|
||||
Font color Color de fuente
|
||||
Highlight Resaltado
|
||||
Justified Justificado
|
||||
Left indent Sangría izquierda
|
||||
Line spacing Interlineado
|
||||
Outline level Nivel de esquema
|
||||
Raised/lowered Elevado/bajado
|
||||
Right indent Sangría derecha
|
||||
Scale Escala
|
||||
Space after Espacio posterior
|
||||
Space before Espacio anterior
|
||||
Suppress line numbers Suprimir números de línea
|
||||
Underline color Color de subrayado
|
||||
Letters Cartas
|
||||
E-mail Messages Mensajes de correo electrónico
|
||||
Directory Directorio
|
||||
|
|
@ -966,3 +1048,25 @@
|
|||
Offset: Top Right Desplazamiento: arriba a la derecha
|
||||
Offset: Top Desplazamiento: arriba
|
||||
Offset: Top Left Desplazamiento: arriba a la izquierda
|
||||
Manage Styles Administrar estilos
|
||||
Show or Hide Style Mostrar u ocultar estilo
|
||||
Manage Styles… Administrar estilos…
|
||||
Sort order: Orden:
|
||||
As Recommended Recomendado
|
||||
Alphabetical Alfabético
|
||||
Font: {font} Fuente: {font}
|
||||
{size} pt {size} pt
|
||||
Color: {color} Color: {color}
|
||||
Space before: {pt} pt Espacio antes: {pt} pt
|
||||
Space after: {pt} pt Espacio después: {pt} pt
|
||||
Based on: {style} Basado en: {style}
|
||||
Paragraph style Estilo de párrafo
|
||||
Character style Estilo de carácter
|
||||
Linked style (paragraph and character) Estilo vinculado (párrafo y carácter)
|
||||
Table style Estilo de tabla
|
||||
Built-in style Estilo integrado
|
||||
Show in the Styles gallery Mostrar en la galería de estilos
|
||||
Hide from style lists Ocultar de las listas de estilos
|
||||
Built-in styles can't be deleted. Los estilos integrados no se pueden eliminar.
|
||||
Delete Table Style Eliminar estilo de tabla
|
||||
Preview Vista previa
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -12,7 +12,10 @@
|
|||
Add 追加
|
||||
Add a comment コメントを追加
|
||||
Add a Table 表を追加
|
||||
Add Object to Selection オブジェクトを選択範囲に追加
|
||||
Add Space Before Paragraph 段落前に間隔を追加
|
||||
Add space between Asian and Latin text アジア文字と欧文の間隔を空ける
|
||||
Add space between Asian text and numbers アジア文字と数字の間隔を空ける
|
||||
Add Text テキストの追加
|
||||
Add to Dictionary 辞書に追加
|
||||
Add to Style Gallery スタイル ギャラリーに追加
|
||||
|
|
@ -31,7 +34,10 @@
|
|||
All Caps すべて大文字
|
||||
All caps すべて大文字
|
||||
All Markup すべての変更履歴
|
||||
Allow hanging punctuation 句読点のぶら下げを行う
|
||||
Allow Latin text to wrap in the middle of a word 欧文を単語の途中で折り返す
|
||||
Alt Text 代替テキスト
|
||||
Apply Asian line-breaking rules (kinsoku) アジア文字の禁則処理を行う
|
||||
Apply Character Style 文字スタイルを適用
|
||||
Apply Style スタイルを適用
|
||||
Apply Styles スタイルを選んで適用
|
||||
|
|
@ -39,6 +45,7 @@
|
|||
Arrange\nAll 整列
|
||||
Arrow 矢印
|
||||
Artistic Effects アート効果
|
||||
Asian Typography アジア文字の組版
|
||||
Author 作成者
|
||||
AutoCorrect Options オートコレクトのオプション
|
||||
AutoFit 自動調整
|
||||
|
|
@ -52,7 +59,10 @@
|
|||
AutoSave 自動保存
|
||||
AutoSave documents you have saved in WordCraft WordCraft で保存した文書を自動保存する
|
||||
AutoSave failed: {error}. Save the document to turn AutoSave back on. 自動保存できませんでした: {error}。文書を保存し直すと、自動保存が再開します。
|
||||
Compress punctuation at the start of a line 行頭の約物を詰める
|
||||
Dark page (white text on black) ダーク ページ (黒地に白い文字)
|
||||
Indents and Spacing インデントと間隔
|
||||
Line breaking 行の分割
|
||||
More templates → その他のテンプレート →
|
||||
No templates match your search. 検索に一致するテンプレートはありません。
|
||||
Search for templates テンプレートを検索
|
||||
|
|
@ -117,6 +127,15 @@
|
|||
Clear クリア
|
||||
Clear All Formatting すべての書式をクリア
|
||||
Clipboard クリップボード
|
||||
Clipboard Pane クリップボード ウィンドウ
|
||||
Clipboard Items クリップボードの項目
|
||||
Paste Clipboard Item クリップボードの項目を貼り付け
|
||||
Paste All すべて貼り付け
|
||||
Delete Clipboard Item クリップボードの項目を削除
|
||||
Clear All すべてクリア
|
||||
Click an item to paste it. 項目をクリックすると貼り付けます。
|
||||
Nothing collected yet. Items you copy or cut appear here. まだ何もありません。コピーまたは切り取った項目がここに表示されます。
|
||||
(picture or object) (画像またはオブジェクト)
|
||||
Close 閉じる
|
||||
Close Header and Footer ヘッダーとフッターを閉じる
|
||||
Collapse Selection 選択範囲を縮小
|
||||
|
|
@ -735,6 +754,7 @@
|
|||
Underline Words Only 単語のみ下線
|
||||
Undo 元に戻す
|
||||
Undo {action} 元に戻す: {action}
|
||||
Ungroup グループ解除
|
||||
Up 上
|
||||
Update Field フィールドの更新
|
||||
Update Index 索引の更新
|
||||
|
|
@ -878,6 +898,68 @@
|
|||
Opening print dialog… 印刷ダイアログを開いています…
|
||||
Print failed: {error} 印刷できませんでした: {error}
|
||||
Couldn't open {path}: {error} {path} を開けませんでした: {error}
|
||||
Column Selection 矩形選択
|
||||
Style Inspector スタイル インスペクター
|
||||
Inspect Styles at Selection 選択箇所のスタイルを調べる
|
||||
Clear Formatting Level 書式のレベルをクリア
|
||||
Characters 文字
|
||||
Direct formatting 直接の書式
|
||||
Reset to Normal 標準に戻す
|
||||
Clear Paragraph Formatting 段落の書式をクリア
|
||||
Clear Character Style 文字スタイルをクリア
|
||||
Clear Character Formatting 文字の書式をクリア
|
||||
Paste Special 形式を選択して貼り付け
|
||||
Paste Special… 形式を選択して貼り付け…
|
||||
Paste as: 貼り付ける形式:
|
||||
The clipboard is empty. クリップボードは空です。
|
||||
Formatted Text 書式付きテキスト
|
||||
Rich Text Format (RTF) リッチ テキスト形式 (RTF)
|
||||
HTML Format HTML 形式
|
||||
Unformatted Text 書式なしテキスト
|
||||
Result 結果
|
||||
Keeps the fonts and formatting of the copied text. コピーしたテキストのフォントと書式を保持します。
|
||||
Reads the clipboard's rich text and keeps its formatting. クリップボードの RTF を読み込み、書式を保持します。
|
||||
Reads the clipboard's web content and keeps its formatting. クリップボードの Web コンテンツを読み込み、書式を保持します。
|
||||
Drops all formatting and pastes plain text. 書式をすべて取り除き、プレーン テキストとして貼り付けます。
|
||||
Advanced Find 高度な検索
|
||||
Advanced Find… 高度な検索…
|
||||
Go To… ジャンプ…
|
||||
Reading Highlight 検索結果を強調表示
|
||||
New Table Style 新しい表スタイル
|
||||
Modify Table Style 表スタイルの変更
|
||||
New Table Style… 新しい表スタイル…
|
||||
Modify Table Style… 表スタイルの変更…
|
||||
Apply formatting to: 書式の適用先:
|
||||
Whole Table 表全体
|
||||
Fill color: 塗りつぶしの色:
|
||||
{count} items highlighted {count} 件を強調表示しました
|
||||
{n} pt {n} pt
|
||||
Not {name} {name} なし
|
||||
Level {n} レベル {n}
|
||||
Body Text 本文
|
||||
{n} lines {n} 行
|
||||
At least {n} 最小値 {n}
|
||||
Exactly {n} 固定値 {n}
|
||||
Centered 中央揃え
|
||||
Character spacing 文字間隔
|
||||
Don't add space between paragraphs of the same style 同じスタイルの段落間に間隔を追加しない
|
||||
Don't hyphenate ハイフネーションなし
|
||||
Double strikethrough 二重取り消し線
|
||||
Drop cap ドロップ キャップ
|
||||
First line 最初の行
|
||||
Font color フォントの色
|
||||
Highlight 蛍光ペン
|
||||
Justified 両端揃え
|
||||
Left indent 左インデント
|
||||
Line spacing 行間
|
||||
Outline level アウトライン レベル
|
||||
Raised/lowered 上げ/下げ
|
||||
Right indent 右インデント
|
||||
Scale 倍率
|
||||
Space after 段落後の間隔
|
||||
Space before 段落前の間隔
|
||||
Suppress line numbers 行番号を表示しない
|
||||
Underline color 下線の色
|
||||
Letters レター
|
||||
E-mail Messages 電子メール
|
||||
Directory ディレクトリ
|
||||
|
|
@ -949,3 +1031,25 @@
|
|||
Offset: Top Right オフセット: 右上
|
||||
Offset: Top オフセット: 上
|
||||
Offset: Top Left オフセット: 左上
|
||||
Manage Styles スタイルの管理
|
||||
Show or Hide Style スタイルの表示/非表示
|
||||
Manage Styles… スタイルの管理…
|
||||
Sort order: 並べ替え順:
|
||||
As Recommended 推奨順
|
||||
Alphabetical アルファベット順
|
||||
Font: {font} フォント: {font}
|
||||
{size} pt {size} pt
|
||||
Color: {color} 色: {color}
|
||||
Space before: {pt} pt 前の間隔: {pt} pt
|
||||
Space after: {pt} pt 後の間隔: {pt} pt
|
||||
Based on: {style} 基準: {style}
|
||||
Paragraph style 段落スタイル
|
||||
Character style 文字スタイル
|
||||
Linked style (paragraph and character) リンク スタイル (段落と文字)
|
||||
Table style 表スタイル
|
||||
Built-in style 組み込みスタイル
|
||||
Show in the Styles gallery スタイル ギャラリーに表示
|
||||
Hide from style lists スタイルの一覧に表示しない
|
||||
Built-in styles can't be deleted. 組み込みスタイルは削除できません。
|
||||
Delete Table Style 表スタイルの削除
|
||||
Preview プレビュー
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 3.
|
|
|
@ -47,7 +47,7 @@ pub struct LangInfo {
|
|||
}
|
||||
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 7] = [
|
||||
pub static LANGUAGES: [LangInfo; 9] = [
|
||||
LangInfo { code: "en", name: "English", source: "", prefer_hans: false, catalog: OnceLock::new() },
|
||||
// Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` resolve here (see `candidates`).
|
||||
LangInfo { code: "zh-hans", name: "简体中文", source: include_str!("zh-hans.tsv"), prefer_hans: true, catalog: OnceLock::new() },
|
||||
|
|
@ -60,6 +60,12 @@ pub static LANGUAGES: [LangInfo; 7] = [
|
|||
// Brazilian Portuguese; `pt-BR` and `pt-BR-*` resolve here. Plain `pt` and `pt-PT` have no
|
||||
// catalog yet (the European vocabulary differs), so they stay in English.
|
||||
LangInfo { code: "pt-br", name: "Português (Brasil)", source: include_str!("pt-br.tsv"), prefer_hans: false, catalog: OnceLock::new() },
|
||||
// Serbian, Cyrillic script (the default per BCP 47 when no script is given); `sr`, `sr-RS`,
|
||||
// `sr-Cyrl-*` resolve here.
|
||||
LangInfo { code: "sr", name: "Српски", source: include_str!("sr.tsv"), prefer_hans: false, catalog: OnceLock::new() },
|
||||
// Serbian, Latin script; `sr-Latn-*` resolves here (see `candidates`'s generic prefix
|
||||
// matching — no special-casing needed, unlike Chinese's script-by-region fallback).
|
||||
LangInfo { code: "sr-latn", name: "Srpski (latinica)", source: include_str!("sr-latn.tsv"), prefer_hans: false, catalog: OnceLock::new() },
|
||||
];
|
||||
|
||||
impl LangInfo {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,10 @@
|
|||
Add Adicionar
|
||||
Add a comment Adicionar um comentário
|
||||
Add a Table Adicionar uma tabela
|
||||
Add Object to Selection Adicionar objeto à seleção
|
||||
Add Space Before Paragraph Adicionar espaço antes do parágrafo
|
||||
Add space between Asian and Latin text Adicionar espaço entre texto asiático e latino
|
||||
Add space between Asian text and numbers Adicionar espaço entre texto asiático e números
|
||||
Add Text Adicionar texto
|
||||
Add to Dictionary Adicionar ao dicionário
|
||||
Add to Style Gallery Adicionar à galeria de estilos
|
||||
|
|
@ -42,13 +45,17 @@
|
|||
All Caps Tudo em maiúsculas
|
||||
All caps Tudo em maiúsculas
|
||||
All Markup Todas as marcações
|
||||
Allow hanging punctuation Permitir pontuação pendente
|
||||
Allow Latin text to wrap in the middle of a word Permitir que o texto latino quebre no meio de uma palavra
|
||||
Alt Text Texto alternativo
|
||||
Apply Asian line-breaking rules (kinsoku) Aplicar as regras asiáticas de quebra de linha (kinsoku)
|
||||
Apply Character Style Aplicar estilo de caractere
|
||||
Apply Style Aplicar estilo
|
||||
Arrange Organizar
|
||||
Arrange\nAll Organizar\ntudo
|
||||
Arrow Seta
|
||||
Artistic Effects Efeitos artísticos
|
||||
Asian Typography Tipografia asiática
|
||||
Author Autor
|
||||
AutoCorrect Options Opções de autocorreção
|
||||
AutoFit Autoajuste
|
||||
|
|
@ -119,6 +126,15 @@
|
|||
Clear Limpar
|
||||
Clear All Formatting Limpar toda a formatação
|
||||
Clipboard Área de transferência
|
||||
Clipboard Pane Painel da área de transferência
|
||||
Clipboard Items Itens da área de transferência
|
||||
Paste Clipboard Item Colar item da área de transferência
|
||||
Paste All Colar tudo
|
||||
Delete Clipboard Item Excluir item da área de transferência
|
||||
Clear All Limpar tudo
|
||||
Click an item to paste it. Clique em um item para colá-lo.
|
||||
Nothing collected yet. Items you copy or cut appear here. Nada coletado ainda. Os itens que você copiar ou recortar aparecem aqui.
|
||||
(picture or object) (imagem ou objeto)
|
||||
Close Fechar
|
||||
Close Header and Footer Fechar cabeçalho e rodapé
|
||||
Collapse Selection Recolher seleção
|
||||
|
|
@ -137,6 +153,7 @@
|
|||
Compact Compacto
|
||||
Compare Comparar
|
||||
Compress Pictures Compactar imagens
|
||||
Compress punctuation at the start of a line Comprimir a pontuação no início da linha
|
||||
Continuous Contínua
|
||||
Convert Converter
|
||||
Convert Text to Table Converter texto em tabela
|
||||
|
|
@ -179,6 +196,8 @@
|
|||
Delete Style Excluir estilo
|
||||
Delete Table Excluir tabela
|
||||
Design Design
|
||||
Indents and Spacing Recuos e espaçamento
|
||||
Line breaking Quebra de linhas
|
||||
More templates → Mais modelos →
|
||||
No templates match your search. Nenhum modelo corresponde à pesquisa.
|
||||
Search for templates Pesquisar modelos
|
||||
|
|
@ -735,6 +754,7 @@
|
|||
Underline Words Only Sublinhar somente palavras
|
||||
Undo Desfazer
|
||||
Undo {action} Desfazer {action}
|
||||
Ungroup Desagrupar
|
||||
Up Para cima
|
||||
Update Field Atualizar campo
|
||||
Update Index Atualizar índice
|
||||
|
|
@ -890,6 +910,68 @@
|
|||
Opening print dialog… Abrindo a caixa de diálogo de impressão…
|
||||
Print failed: {error} Falha na impressão: {error}
|
||||
Couldn't open {path}: {error} Não foi possível abrir {path}: {error}
|
||||
Column Selection Seleção de coluna
|
||||
Style Inspector Inspetor de estilos
|
||||
Inspect Styles at Selection Inspecionar estilos na seleção
|
||||
Clear Formatting Level Limpar nível de formatação
|
||||
Characters Caracteres
|
||||
Direct formatting Formatação direta
|
||||
Reset to Normal Redefinir para Normal
|
||||
Clear Paragraph Formatting Limpar formatação do parágrafo
|
||||
Clear Character Style Limpar estilo de caractere
|
||||
Clear Character Formatting Limpar formatação de caractere
|
||||
Paste Special Colar especial
|
||||
Paste Special… Colar especial…
|
||||
Paste as: Colar como:
|
||||
The clipboard is empty. A área de transferência está vazia.
|
||||
Formatted Text Texto formatado
|
||||
Rich Text Format (RTF) Rich Text Format (RTF)
|
||||
HTML Format Formato HTML
|
||||
Unformatted Text Texto sem formatação
|
||||
Result Resultado
|
||||
Keeps the fonts and formatting of the copied text. Mantém as fontes e a formatação do texto copiado.
|
||||
Reads the clipboard's rich text and keeps its formatting. Lê o rich text da área de transferência e mantém a formatação.
|
||||
Reads the clipboard's web content and keeps its formatting. Lê o conteúdo web da área de transferência e mantém a formatação.
|
||||
Drops all formatting and pastes plain text. Remove toda a formatação e cola texto simples.
|
||||
Advanced Find Localização avançada
|
||||
Advanced Find… Localização avançada…
|
||||
Go To… Ir para…
|
||||
Reading Highlight Realçar resultados
|
||||
New Table Style Novo estilo de tabela
|
||||
Modify Table Style Modificar estilo de tabela
|
||||
New Table Style… Novo estilo de tabela…
|
||||
Modify Table Style… Modificar estilo de tabela…
|
||||
Apply formatting to: Aplicar formatação a:
|
||||
Whole Table Tabela inteira
|
||||
Fill color: Cor de preenchimento:
|
||||
{count} items highlighted {count} itens realçados
|
||||
{n} pt {n} pt
|
||||
Not {name} Sem {name}
|
||||
Level {n} Nível {n}
|
||||
Body Text Corpo do texto
|
||||
{n} lines {n} linhas
|
||||
At least {n} Pelo menos {n}
|
||||
Exactly {n} Exatamente {n}
|
||||
Centered Centralizado
|
||||
Character spacing Espaçamento entre caracteres
|
||||
Don't add space between paragraphs of the same style Não adicionar espaço entre parágrafos do mesmo estilo
|
||||
Don't hyphenate Não hifenizar
|
||||
Double strikethrough Tachado duplo
|
||||
Drop cap Capitular
|
||||
First line Primeira linha
|
||||
Font color Cor da fonte
|
||||
Highlight Realce
|
||||
Justified Justificado
|
||||
Left indent Recuo à esquerda
|
||||
Line spacing Espaçamento entre linhas
|
||||
Outline level Nível de tópicos
|
||||
Raised/lowered Elevado/rebaixado
|
||||
Right indent Recuo à direita
|
||||
Scale Escala
|
||||
Space after Espaço depois
|
||||
Space before Espaço antes
|
||||
Suppress line numbers Suprimir números de linha
|
||||
Underline color Cor do sublinhado
|
||||
Letters Cartas
|
||||
E-mail Messages Mensagens de email
|
||||
Directory Diretório
|
||||
|
|
@ -961,3 +1043,25 @@
|
|||
Offset: Top Right Deslocamento: superior direito
|
||||
Offset: Top Deslocamento: superior
|
||||
Offset: Top Left Deslocamento: superior esquerdo
|
||||
Manage Styles Gerenciar estilos
|
||||
Show or Hide Style Mostrar ou ocultar estilo
|
||||
Manage Styles… Gerenciar estilos…
|
||||
Sort order: Ordem:
|
||||
As Recommended Recomendada
|
||||
Alphabetical Alfabética
|
||||
Font: {font} Fonte: {font}
|
||||
{size} pt {size} pt
|
||||
Color: {color} Cor: {color}
|
||||
Space before: {pt} pt Espaço antes: {pt} pt
|
||||
Space after: {pt} pt Espaço depois: {pt} pt
|
||||
Based on: {style} Baseado em: {style}
|
||||
Paragraph style Estilo de parágrafo
|
||||
Character style Estilo de caractere
|
||||
Linked style (paragraph and character) Estilo vinculado (parágrafo e caractere)
|
||||
Table style Estilo de tabela
|
||||
Built-in style Estilo integrado
|
||||
Show in the Styles gallery Mostrar na galeria de estilos
|
||||
Hide from style lists Ocultar das listas de estilos
|
||||
Built-in styles can't be deleted. Estilos integrados não podem ser excluídos.
|
||||
Delete Table Style Excluir estilo de tabela
|
||||
Preview Visualização
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -25,6 +25,11 @@ fn system_locales_map_to_languages() {
|
|||
assert_eq!(lang_from_tag("fr-FR"), None);
|
||||
assert_eq!(lang_from_tag(""), None);
|
||||
assert_eq!(lang_from_tag("_"), None);
|
||||
assert_eq!(lang_from_tag("sr"), Some(lang("sr")), "Cyrillic is the default script");
|
||||
assert_eq!(lang_from_tag("sr-RS"), Some(lang("sr")));
|
||||
assert_eq!(lang_from_tag("sr-Cyrl-RS"), Some(lang("sr")));
|
||||
assert_eq!(lang_from_tag("sr-Latn-RS"), Some(lang("sr-latn")));
|
||||
assert_eq!(lang_from_tag("sr-Latn"), Some(lang("sr-latn")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -209,6 +214,69 @@ fn bundled_interface_fonts_cover_ukrainian_without_system_fallbacks() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serbian_covers_the_entire_existing_interface_catalog() {
|
||||
use std::collections::HashSet;
|
||||
let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::<HashSet<_>>();
|
||||
let reference = keys(lang("zh-hans").0.source);
|
||||
assert_eq!(keys(lang("sr").0.source), reference);
|
||||
assert_eq!(keys(lang("sr-latn").0.source), reference);
|
||||
let (cyr, lat) = (lang("sr"), lang("sr-latn"));
|
||||
assert_eq!(tr(cyr, "Home"), "Почетак");
|
||||
assert_eq!(tr(lat, "Home"), "Početak");
|
||||
assert_eq!(tr(cyr, "unknown future label"), "unknown future label");
|
||||
assert_eq!(fmt(tr(lat, "Exported {path}"), &[("path", "draft-1.docx")]), "Izvezeno: draft-1.docx");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_interface_fonts_cover_serbian_cyrillic_without_system_fallbacks() {
|
||||
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
|
||||
let (entries, errors) = parse_entries(lang("sr").0.source);
|
||||
assert!(errors.is_empty());
|
||||
let chars: std::collections::HashSet<char> =
|
||||
entries.iter().flat_map(|e| e.translation.chars()).filter(|c| ('\u{0400}'..='\u{04ff}').contains(c)).collect();
|
||||
for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] {
|
||||
let face = db.face(family, style);
|
||||
assert_eq!(face.family, family, "must use the bundled interface face");
|
||||
for ch in &chars {
|
||||
assert_ne!(face.glyph_for(*ch), 0, "{family} {style} lacks {ch}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_interface_fonts_cover_serbian_latin_without_system_fallbacks() {
|
||||
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
|
||||
let (entries, errors) = parse_entries(lang("sr-latn").0.source);
|
||||
assert!(errors.is_empty());
|
||||
// Serbian Latin's letters beyond plain ASCII: š č ć ž đ (and their capitals).
|
||||
let extra = ['š', 'č', 'ć', 'ž', 'đ', 'Š', 'Č', 'Ć', 'Ž', 'Đ'];
|
||||
let chars: std::collections::HashSet<char> =
|
||||
entries.iter().flat_map(|e| e.translation.chars()).filter(|c| extra.contains(c)).chain(extra).collect();
|
||||
for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] {
|
||||
let face = db.face(family, style);
|
||||
assert_eq!(face.family, family, "must use the bundled interface face");
|
||||
for ch in &chars {
|
||||
assert_ne!(face.glyph_for(*ch), 0, "{family} {style} lacks {ch}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serbian_locales_resolve_by_script() {
|
||||
let cyr = lang("sr");
|
||||
let lat = lang("sr-latn");
|
||||
for tag in ["sr", "sr-RS", "sr_RS.UTF-8", "SR", "sr-Cyrl", "sr-Cyrl-RS"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(cyr), "{tag}");
|
||||
}
|
||||
for tag in ["sr-Latn", "sr-Latn-RS", "sr-latn-me"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(lat), "{tag}");
|
||||
}
|
||||
assert_eq!(first_supported(["fr-FR", "sr-Latn-RS", "en-US"]), Some(lat));
|
||||
assert_eq!(tr(cyr, "File"), "Фајл");
|
||||
assert_eq!(tr(lat, "File"), "Fajl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brazilian_portuguese_locales_and_saved_preference_work_without_changing_the_document() {
|
||||
let pt = lang("pt-br");
|
||||
|
|
|
|||
|
|
@ -23,7 +23,10 @@
|
|||
Add Додати
|
||||
Add a comment Додати коментар
|
||||
Add a Table Додати таблицю
|
||||
Add Object to Selection Додати об'єкт до виділення
|
||||
Add Space Before Paragraph Додати інтервал перед абзацом
|
||||
Add space between Asian and Latin text Додавати інтервал між азійським і латинським текстом
|
||||
Add space between Asian text and numbers Додавати інтервал між азійським текстом і числами
|
||||
Add Text Додати текст
|
||||
Add to Dictionary Додати до словника
|
||||
Add to Style Gallery Додати до колекції стилів
|
||||
|
|
@ -32,6 +35,8 @@
|
|||
Address\nBlock Блок\nадреси
|
||||
Adjust Налаштувати
|
||||
Advanced Додатково
|
||||
Advanced Find Розширений пошук
|
||||
Advanced Find… Розширений пошук…
|
||||
After: Після:
|
||||
Agents Агенти
|
||||
Align Вирівняти
|
||||
|
|
@ -42,13 +47,17 @@
|
|||
All Caps Усі великі літери
|
||||
All caps Усі великі літери
|
||||
All Markup Усі позначки
|
||||
Allow hanging punctuation Дозволити висячу пунктуацію
|
||||
Allow Latin text to wrap in the middle of a word Дозволити розрив латинського тексту посеред слова
|
||||
Alt Text Альтернативний текст
|
||||
Apply Asian line-breaking rules (kinsoku) Застосовувати азійські правила розриву рядків (кінсоку)
|
||||
Apply Character Style Застосувати стиль символів
|
||||
Apply Style Застосувати стиль
|
||||
Arrange Упорядкувати
|
||||
Arrange\nAll Упорядкувати\nвсі
|
||||
Arrow Стрілка
|
||||
Artistic Effects Художні ефекти
|
||||
Asian Typography Азійська типографіка
|
||||
Author Автор
|
||||
AutoCorrect Options Параметри автовиправлення
|
||||
AutoFit Добір розміру
|
||||
|
|
@ -106,6 +115,7 @@
|
|||
Character Scale Масштаб символів
|
||||
Character Shading Заливка символів
|
||||
Character Spacing Інтервал між символами
|
||||
Characters Символи
|
||||
Characters (no spaces) Символи (без пробілів)
|
||||
Characters (with spaces) Символи (з пробілами)
|
||||
Chart Діаграма
|
||||
|
|
@ -117,8 +127,21 @@
|
|||
Citation Style Стиль цитування
|
||||
Citations & Bibliography Цитати й бібліографія
|
||||
Clear Очистити
|
||||
Clear All Очистити все
|
||||
Clear All Formatting Очистити все форматування
|
||||
Clear Character Formatting Очистити форматування символів
|
||||
Clear Character Style Очистити стиль символів
|
||||
Clear Formatting Level Очистити рівень форматування
|
||||
Clear Paragraph Formatting Очистити форматування абзацу
|
||||
Clipboard Буфер обміну
|
||||
Clipboard Pane Панель буфера обміну
|
||||
Clipboard Items Елементи буфера обміну
|
||||
Paste Clipboard Item Вставити елемент буфера обміну
|
||||
Paste All Вставити все
|
||||
Delete Clipboard Item Видалити елемент буфера обміну
|
||||
Click an item to paste it. Клацніть елемент, щоб вставити його.
|
||||
Nothing collected yet. Items you copy or cut appear here. Поки що порожньо. Тут з'являться елементи, які ви скопіюєте або виріжете.
|
||||
(picture or object) (зображення або об'єкт)
|
||||
Close Закрити
|
||||
Close Header and Footer Закрити колонтитули
|
||||
Collapse Selection Зняти виділення
|
||||
|
|
@ -137,6 +160,7 @@
|
|||
Compact Компактний
|
||||
Compare Порівняти
|
||||
Compress Pictures Стиснути зображення
|
||||
Compress punctuation at the start of a line Стискати розділові знаки на початку рядка
|
||||
Continuous Безперервний
|
||||
Convert Перетворити
|
||||
Convert Text to Table Перетворити текст на таблицю
|
||||
|
|
@ -179,6 +203,8 @@
|
|||
Delete Style Видалити стиль
|
||||
Delete Table Видалити таблицю
|
||||
Design Дизайн
|
||||
Indents and Spacing Відступи й інтервали
|
||||
Line breaking Розрив рядків
|
||||
More templates → Більше шаблонів →
|
||||
No templates match your search. Немає шаблонів, що відповідають пошуку.
|
||||
Search for templates Пошук шаблонів
|
||||
|
|
@ -188,6 +214,7 @@
|
|||
Dictate Диктувати
|
||||
Different First Page Інша перша сторінка
|
||||
Different Odd & Even Pages Різні парні й непарні сторінки
|
||||
Direct formatting Пряме форматування
|
||||
Discord Discord
|
||||
Discord community Спільнота Discord
|
||||
Display Відображення
|
||||
|
|
@ -215,6 +242,7 @@
|
|||
Drawing Tools Знаряддя малювання
|
||||
Drawing\nCanvas Полотно\nмалювання
|
||||
Drop Cap Буквиця
|
||||
Drops all formatting and pastes plain text. Відкидає все форматування й вставляє звичайний текст.
|
||||
Edit Footer Редагувати нижній колонтитул
|
||||
Edit Header Редагувати верхній колонтитул
|
||||
Edit Recipient List Редагувати список одержувачів
|
||||
|
|
@ -271,12 +299,14 @@
|
|||
Format Page Numbers Формат номерів сторінок
|
||||
Format Page Numbers… Формат номерів сторінок…
|
||||
Format Painter Пензель форматування
|
||||
Formatted Text Форматований текст
|
||||
Formatting at Selection Форматування виділення
|
||||
Formula Формула
|
||||
General Загальні
|
||||
Go To Перейти
|
||||
Go to Перейти
|
||||
Go To (⌘⌥G) Перейти (⌘⌥G)
|
||||
Go To… Перейти…
|
||||
Grab bag Різне
|
||||
Greeting Line Рядок привітання
|
||||
Greeting\nLine Рядок\nпривітання
|
||||
|
|
@ -301,6 +331,7 @@
|
|||
Home Основне
|
||||
Horizontal Горизонтальна
|
||||
Horizontal Line Горизонтальна лінія
|
||||
HTML Format Формат HTML
|
||||
Hyphenation Перенесення слів
|
||||
Icons Піктограми
|
||||
Ignore All Ігнорувати всі
|
||||
|
|
@ -342,6 +373,7 @@
|
|||
Insert\nFootnote Вставити\nвиноску
|
||||
Inside Borders Внутрішні межі
|
||||
Inspect Document Перевірити документ
|
||||
Inspect Styles at Selection Переглянути стилі у виділенні
|
||||
Interface language: Мова інтерфейсу:
|
||||
Italic Курсив
|
||||
Join the ArtCraft community on Discord Приєднатися до спільноти ArtCraft у Discord
|
||||
|
|
@ -352,6 +384,7 @@
|
|||
Keep Text Only Зберегти лише текст
|
||||
Keep with Next Не відривати від наступного
|
||||
Keep with next Не відривати від наступного
|
||||
Keeps the fonts and formatting of the copied text. Зберігає шрифти й форматування скопійованого тексту.
|
||||
Labels Етикетки
|
||||
Landscape Альбомна
|
||||
Language Мова
|
||||
|
|
@ -493,7 +526,10 @@
|
|||
Paragraphs Абзаци
|
||||
Paste Вставити
|
||||
Paste (⌘V) Вставити (⌘V)
|
||||
Paste as: Вставити як:
|
||||
Paste Formatting Вставити форматування
|
||||
Paste Special Спеціальна вставка
|
||||
Paste Special… Спеціальна вставка…
|
||||
Paste: Keep Text Only Вставлення: зберегти лише текст
|
||||
Paste: Merge Formatting Вставлення: об'єднати форматування
|
||||
PDF document (*.pdf) Документ PDF (*.pdf)
|
||||
|
|
@ -528,6 +564,9 @@
|
|||
Read Mode Режим читання
|
||||
Read\nAloud Читати\nвголос
|
||||
Read\nMode Режим\nчитання
|
||||
Reads the clipboard's rich text and keeps its formatting. Читає форматований текст (RTF) із буфера обміну та зберігає його форматування.
|
||||
Reads the clipboard's web content and keeps its formatting. Читає вебвміст із буфера обміну та зберігає його форматування.
|
||||
Reading Highlight Виділення під час читання
|
||||
Recent Останні
|
||||
Record Macro Записати макрос
|
||||
Recover Unsaved Documents Відновити незбережені документи
|
||||
|
|
@ -558,6 +597,7 @@
|
|||
Research Дослідження
|
||||
Researcher Дослідник
|
||||
Reset Picture Скинути зображення
|
||||
Reset to Normal Скинути до звичайного
|
||||
Resolve Вирішити
|
||||
Resolved Вирішено
|
||||
Restart at 1 Почати з 1
|
||||
|
|
@ -565,6 +605,7 @@
|
|||
Restart Each Section Починати в кожному розділі
|
||||
Restrict Editing Обмежити редагування
|
||||
Restrict\nEditing Обмежити\nредагування
|
||||
Result Результат
|
||||
Results Результати
|
||||
Reverse the order Зворотний порядок
|
||||
Review Рецензування
|
||||
|
|
@ -572,6 +613,7 @@
|
|||
Reviewing Pane Панель рецензування
|
||||
Reviewing — edits become suggestions Рецензування — зміни стають пропозиціями
|
||||
Rich Text Format (*.rtf) Форматований текст (*.rtf)
|
||||
Rich Text Format (RTF) Формат RTF (Rich Text Format)
|
||||
Right Праворуч
|
||||
Right Border Права межа
|
||||
Right-to-Left Text Direction Напрямок тексту справа наліво
|
||||
|
|
@ -671,6 +713,7 @@
|
|||
Studio handbook (sample) Посібник студії (приклад)
|
||||
Style Стиль
|
||||
Style based on: Стиль на основі:
|
||||
Style Inspector Інспектор стилів
|
||||
Style Set Набір стилів
|
||||
Style: APA Стиль: APA
|
||||
Styles Стилі
|
||||
|
|
@ -709,6 +752,7 @@
|
|||
Text: Текст:
|
||||
Text\nBox Текстове\nполе
|
||||
Text\nDirection Напрямок\nтексту
|
||||
The clipboard is empty. Буфер обміну порожній.
|
||||
The new style takes the formatting of the current paragraph. Новий стиль використовує форматування поточного абзацу.
|
||||
Theme Colors Кольори теми
|
||||
Theme Fonts Шрифти теми
|
||||
|
|
@ -735,6 +779,8 @@
|
|||
Underline Words Only Підкреслити лише слова
|
||||
Undo Скасувати
|
||||
Undo {action} Скасувати {action}
|
||||
Ungroup Розгрупувати
|
||||
Unformatted Text Неформатований текст
|
||||
Up Угору
|
||||
Update Field Оновити поле
|
||||
Update Index Оновити покажчик
|
||||
|
|
@ -890,6 +936,42 @@
|
|||
Opening print dialog… Відкриття діалогу друку…
|
||||
Print failed: {error} Не вдалося надрукувати: {error}
|
||||
Couldn't open {path}: {error} Не вдалося відкрити {path}: {error}
|
||||
Column Selection Виділення стовпця
|
||||
New Table Style Новий стиль таблиці
|
||||
Modify Table Style Змінити стиль таблиці
|
||||
New Table Style… Новий стиль таблиці…
|
||||
Modify Table Style… Змінити стиль таблиці…
|
||||
Apply formatting to: Застосувати форматування до:
|
||||
Whole Table Уся таблиця
|
||||
Fill color: Колір заливки:
|
||||
{count} items highlighted Виділено елементів: {count}
|
||||
{n} pt {n} пт
|
||||
Not {name} Без: {name}
|
||||
Level {n} Рівень {n}
|
||||
Body Text Основний текст
|
||||
{n} lines Рядків: {n}
|
||||
At least {n} Щонайменше {n}
|
||||
Exactly {n} Точно {n}
|
||||
Centered По центру
|
||||
Character spacing Інтервал між символами
|
||||
Don't add space between paragraphs of the same style Не додавати інтервал між абзацами одного стилю
|
||||
Don't hyphenate Без перенесення слів
|
||||
Double strikethrough Подвійне закреслення
|
||||
Drop cap Буквиця
|
||||
First line Перший рядок
|
||||
Font color Колір шрифту
|
||||
Highlight Виділення кольором
|
||||
Justified За шириною
|
||||
Left indent Відступ ліворуч
|
||||
Line spacing Міжрядковий інтервал
|
||||
Outline level Рівень плану
|
||||
Raised/lowered Піднято/опущено
|
||||
Right indent Відступ праворуч
|
||||
Scale Масштаб
|
||||
Space after Інтервал після
|
||||
Space before Інтервал перед
|
||||
Suppress line numbers Без номерів рядків
|
||||
Underline color Колір підкреслення
|
||||
Letters Листи
|
||||
E-mail Messages Повідомлення електронної пошти
|
||||
Directory Каталог
|
||||
|
|
@ -961,3 +1043,25 @@
|
|||
Offset: Top Right Зміщення: вгору праворуч
|
||||
Offset: Top Зміщення: вгору
|
||||
Offset: Top Left Зміщення: вгору ліворуч
|
||||
Manage Styles Керування стилями
|
||||
Show or Hide Style Показати або сховати стиль
|
||||
Manage Styles… Керування стилями…
|
||||
Sort order: Порядок сортування:
|
||||
As Recommended Як рекомендовано
|
||||
Alphabetical За абеткою
|
||||
Font: {font} Шрифт: {font}
|
||||
{size} pt {size} пт
|
||||
Color: {color} Колір: {color}
|
||||
Space before: {pt} pt Інтервал перед: {pt} пт
|
||||
Space after: {pt} pt Інтервал після: {pt} пт
|
||||
Based on: {style} На основі: {style}
|
||||
Paragraph style Стиль абзацу
|
||||
Character style Стиль символів
|
||||
Linked style (paragraph and character) Зв’язаний стиль (абзац і символи)
|
||||
Table style Стиль таблиці
|
||||
Built-in style Вбудований стиль
|
||||
Show in the Styles gallery Показувати в колекції стилів
|
||||
Hide from style lists Сховати зі списків стилів
|
||||
Built-in styles can't be deleted. Вбудовані стилі не можна видалити.
|
||||
Delete Table Style Видалити стиль таблиці
|
||||
Preview Попередній перегляд
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
|
|
@ -23,7 +23,10 @@
|
|||
Add 添加
|
||||
Add a comment 添加批注
|
||||
Add a Table 添加表格
|
||||
Add Object to Selection 将对象添加到所选内容
|
||||
Add Space Before Paragraph 在段前添加间距
|
||||
Add space between Asian and Latin text 在亚洲文字与西文之间添加间距
|
||||
Add space between Asian text and numbers 在亚洲文字与数字之间添加间距
|
||||
Add Text 添加文字
|
||||
Add to Dictionary 添加到词典
|
||||
Add to Style Gallery 添加到样式库
|
||||
|
|
@ -42,7 +45,10 @@
|
|||
All Caps 全部大写
|
||||
All caps 全部大写
|
||||
All Markup 所有标记
|
||||
Allow hanging punctuation 允许标点溢出边界
|
||||
Allow Latin text to wrap in the middle of a word 允许西文在单词中间换行
|
||||
Alt Text 替代文字
|
||||
Apply Asian line-breaking rules (kinsoku) 应用亚洲文字的断行规则(禁则)
|
||||
Apply Character Style 应用字符样式
|
||||
Apply Style 应用样式
|
||||
Apply Styles 选择并应用样式
|
||||
|
|
@ -50,6 +56,7 @@
|
|||
Arrange\nAll 全部重排
|
||||
Arrow 箭头
|
||||
Artistic Effects 艺术效果
|
||||
Asian Typography 亚洲版式
|
||||
Author 作者
|
||||
AutoCorrect Options 自动更正选项
|
||||
AutoFit 自动调整
|
||||
|
|
@ -63,7 +70,10 @@
|
|||
AutoSave 自动保存
|
||||
AutoSave documents you have saved in WordCraft 自动保存在 WordCraft 中保存过的文档
|
||||
AutoSave failed: {error}. Save the document to turn AutoSave back on. 自动保存失败:{error}。重新保存文档后将恢复自动保存。
|
||||
Compress punctuation at the start of a line 压缩行首标点
|
||||
Dark page (white text on black) 深色页面(黑底白字)
|
||||
Indents and Spacing 缩进和间距
|
||||
Line breaking 断行
|
||||
More templates → 更多模板 →
|
||||
No templates match your search. 没有与搜索匹配的模板。
|
||||
Search for templates 搜索模板
|
||||
|
|
@ -128,6 +138,15 @@
|
|||
Clear 清除
|
||||
Clear All Formatting 清除所有格式
|
||||
Clipboard 剪贴板
|
||||
Clipboard Pane 剪贴板窗格
|
||||
Clipboard Items 剪贴板项目
|
||||
Paste Clipboard Item 粘贴剪贴板项目
|
||||
Paste All 全部粘贴
|
||||
Delete Clipboard Item 删除剪贴板项目
|
||||
Clear All 全部清除
|
||||
Click an item to paste it. 单击项目即可粘贴。
|
||||
Nothing collected yet. Items you copy or cut appear here. 尚无内容。复制或剪切的项目会显示在这里。
|
||||
(picture or object) (图片或对象)
|
||||
Close 关闭
|
||||
Close Header and Footer 关闭页眉和页脚
|
||||
Collapse Selection 折叠选定内容
|
||||
|
|
@ -746,6 +765,7 @@
|
|||
Underline Words Only 仅给字词加下划线
|
||||
Undo 撤消
|
||||
Undo {action} 撤消{action}
|
||||
Ungroup 取消组合
|
||||
Up 上
|
||||
Update Field 更新域
|
||||
Update Index 更新索引
|
||||
|
|
@ -889,6 +909,68 @@
|
|||
Opening print dialog… 正在打开打印对话框…
|
||||
Print failed: {error} 打印失败:{error}
|
||||
Couldn't open {path}: {error} 无法打开 {path}:{error}
|
||||
Column Selection 矩形选择
|
||||
Style Inspector 样式检查器
|
||||
Inspect Styles at Selection 检查所选内容的样式
|
||||
Clear Formatting Level 清除格式级别
|
||||
Characters 字符
|
||||
Direct formatting 直接格式
|
||||
Reset to Normal 重置为正文
|
||||
Clear Paragraph Formatting 清除段落格式
|
||||
Clear Character Style 清除字符样式
|
||||
Clear Character Formatting 清除字符格式
|
||||
Paste Special 选择性粘贴
|
||||
Paste Special… 选择性粘贴…
|
||||
Paste as: 粘贴为:
|
||||
The clipboard is empty. 剪贴板为空。
|
||||
Formatted Text 带格式文本
|
||||
Rich Text Format (RTF) RTF 格式
|
||||
HTML Format HTML 格式
|
||||
Unformatted Text 无格式文本
|
||||
Result 结果
|
||||
Keeps the fonts and formatting of the copied text. 保留所复制文本的字体和格式。
|
||||
Reads the clipboard's rich text and keeps its formatting. 读取剪贴板中的 RTF 内容并保留其格式。
|
||||
Reads the clipboard's web content and keeps its formatting. 读取剪贴板中的网页内容并保留其格式。
|
||||
Drops all formatting and pastes plain text. 去除所有格式,粘贴纯文本。
|
||||
Advanced Find 高级查找
|
||||
Advanced Find… 高级查找…
|
||||
Go To… 定位…
|
||||
Reading Highlight 突出显示查找结果
|
||||
New Table Style 新建表格样式
|
||||
Modify Table Style 修改表格样式
|
||||
New Table Style… 新建表格样式…
|
||||
Modify Table Style… 修改表格样式…
|
||||
Apply formatting to: 将格式应用于:
|
||||
Whole Table 整个表格
|
||||
Fill color: 填充颜色:
|
||||
{count} items highlighted 已突出显示 {count} 项
|
||||
{n} pt {n} 磅
|
||||
Not {name} 非{name}
|
||||
Level {n} {n} 级
|
||||
Body Text 正文文本
|
||||
{n} lines {n} 行
|
||||
At least {n} 最小值 {n}
|
||||
Exactly {n} 固定值 {n}
|
||||
Centered 居中
|
||||
Character spacing 字符间距
|
||||
Don't add space between paragraphs of the same style 相同样式的段落间不添加间距
|
||||
Don't hyphenate 不断字
|
||||
Double strikethrough 双删除线
|
||||
Drop cap 首字下沉
|
||||
First line 首行
|
||||
Font color 字体颜色
|
||||
Highlight 突出显示
|
||||
Justified 两端对齐
|
||||
Left indent 左缩进
|
||||
Line spacing 行距
|
||||
Outline level 大纲级别
|
||||
Raised/lowered 提升/降低
|
||||
Right indent 右缩进
|
||||
Scale 缩放
|
||||
Space after 段后间距
|
||||
Space before 段前间距
|
||||
Suppress line numbers 取消行号
|
||||
Underline color 下划线颜色
|
||||
Letters 信函
|
||||
E-mail Messages 电子邮件
|
||||
Directory 目录
|
||||
|
|
@ -960,3 +1042,25 @@
|
|||
Offset: Top Right 偏移:右上
|
||||
Offset: Top 偏移:上
|
||||
Offset: Top Left 偏移:左上
|
||||
Manage Styles 管理样式
|
||||
Show or Hide Style 显示或隐藏样式
|
||||
Manage Styles… 管理样式…
|
||||
Sort order: 排序方式:
|
||||
As Recommended 按推荐
|
||||
Alphabetical 按字母顺序
|
||||
Font: {font} 字体:{font}
|
||||
{size} pt {size} 磅
|
||||
Color: {color} 颜色:{color}
|
||||
Space before: {pt} pt 段前间距:{pt} 磅
|
||||
Space after: {pt} pt 段后间距:{pt} 磅
|
||||
Based on: {style} 基于:{style}
|
||||
Paragraph style 段落样式
|
||||
Character style 字符样式
|
||||
Linked style (paragraph and character) 链接样式(段落和字符)
|
||||
Table style 表格样式
|
||||
Built-in style 内置样式
|
||||
Show in the Styles gallery 在样式库中显示
|
||||
Hide from style lists 在样式列表中隐藏
|
||||
Built-in styles can't be deleted. 内置样式不能删除。
|
||||
Delete Table Style 删除表格样式
|
||||
Preview 预览
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -13,7 +13,10 @@
|
|||
Add 新增
|
||||
Add a comment 新增註解
|
||||
Add a Table 新增表格
|
||||
Add Object to Selection 將物件加入選取範圍
|
||||
Add Space Before Paragraph 在段落前新增間距
|
||||
Add space between Asian and Latin text 在亞洲文字與西文之間加入間距
|
||||
Add space between Asian text and numbers 在亞洲文字與數字之間加入間距
|
||||
Add Text 新增文字
|
||||
Add to Dictionary 新增至字典
|
||||
Add to Style Gallery 新增至樣式庫
|
||||
|
|
@ -32,7 +35,10 @@
|
|||
All Caps 全部大寫
|
||||
All caps 全部大寫
|
||||
All Markup 所有標記
|
||||
Allow hanging punctuation 允許標點溢出邊界
|
||||
Allow Latin text to wrap in the middle of a word 允許西文在單字中間換行
|
||||
Alt Text 替代文字
|
||||
Apply Asian line-breaking rules (kinsoku) 套用亞洲文字的斷行規則(禁則)
|
||||
Apply Character Style 套用字元樣式
|
||||
Apply Style 套用樣式
|
||||
Apply Styles 選擇並套用樣式
|
||||
|
|
@ -40,6 +46,7 @@
|
|||
Arrange\nAll 全部排列
|
||||
Arrow 箭號
|
||||
Artistic Effects 美術效果
|
||||
Asian Typography 亞洲版式
|
||||
Author 作者
|
||||
AutoCorrect Options 自動校正選項
|
||||
AutoFit 自動調整
|
||||
|
|
@ -53,7 +60,10 @@
|
|||
AutoSave 自動儲存
|
||||
AutoSave documents you have saved in WordCraft 自動儲存在 WordCraft 中儲存過的文件
|
||||
AutoSave failed: {error}. Save the document to turn AutoSave back on. 自動儲存失敗:{error}。重新儲存文件後將恢復自動儲存。
|
||||
Compress punctuation at the start of a line 壓縮行首標點
|
||||
Dark page (white text on black) 深色頁面(黑底白字)
|
||||
Indents and Spacing 縮排和間距
|
||||
Line breaking 斷行
|
||||
More templates → 更多範本 →
|
||||
No templates match your search. 沒有符合搜尋的範本。
|
||||
Search for templates 搜尋範本
|
||||
|
|
@ -118,6 +128,15 @@
|
|||
Clear 清除
|
||||
Clear All Formatting 清除所有格式設定
|
||||
Clipboard 剪貼簿
|
||||
Clipboard Pane 剪貼簿窗格
|
||||
Clipboard Items 剪貼簿項目
|
||||
Paste Clipboard Item 貼上剪貼簿項目
|
||||
Paste All 全部貼上
|
||||
Delete Clipboard Item 刪除剪貼簿項目
|
||||
Clear All 全部清除
|
||||
Click an item to paste it. 按一下項目即可貼上。
|
||||
Nothing collected yet. Items you copy or cut appear here. 尚無內容。複製或剪下的項目會顯示在這裡。
|
||||
(picture or object) (圖片或物件)
|
||||
Close 關閉
|
||||
Close Header and Footer 關閉頁首及頁尾
|
||||
Collapse Selection 摺疊選取範圍
|
||||
|
|
@ -736,6 +755,7 @@
|
|||
Underline Words Only 只在文字下加底線
|
||||
Undo 復原
|
||||
Undo {action} 復原{action}
|
||||
Ungroup 取消群組
|
||||
Up 上
|
||||
Update Field 更新功能變數
|
||||
Update Index 更新索引
|
||||
|
|
@ -879,6 +899,68 @@
|
|||
Opening print dialog… 正在開啟列印對話方塊…
|
||||
Print failed: {error} 列印失敗:{error}
|
||||
Couldn't open {path}: {error} 無法開啟 {path}:{error}
|
||||
Column Selection 矩形選取
|
||||
Style Inspector 樣式檢查器
|
||||
Inspect Styles at Selection 檢查選取範圍的樣式
|
||||
Clear Formatting Level 清除格式層級
|
||||
Characters 字元
|
||||
Direct formatting 直接格式設定
|
||||
Reset to Normal 重設為內文
|
||||
Clear Paragraph Formatting 清除段落格式設定
|
||||
Clear Character Style 清除字元樣式
|
||||
Clear Character Formatting 清除字元格式設定
|
||||
Paste Special 選擇性貼上
|
||||
Paste Special… 選擇性貼上…
|
||||
Paste as: 貼上為:
|
||||
The clipboard is empty. 剪貼簿是空的。
|
||||
Formatted Text 格式化文字
|
||||
Rich Text Format (RTF) RTF 格式
|
||||
HTML Format HTML 格式
|
||||
Unformatted Text 純文字
|
||||
Result 結果
|
||||
Keeps the fonts and formatting of the copied text. 保留所複製文字的字型和格式。
|
||||
Reads the clipboard's rich text and keeps its formatting. 讀取剪貼簿中的 RTF 內容並保留其格式。
|
||||
Reads the clipboard's web content and keeps its formatting. 讀取剪貼簿中的網頁內容並保留其格式。
|
||||
Drops all formatting and pastes plain text. 移除所有格式,貼上純文字。
|
||||
Advanced Find 進階尋找
|
||||
Advanced Find… 進階尋找…
|
||||
Go To… 移至…
|
||||
Reading Highlight 醒目提示尋找結果
|
||||
New Table Style 新增表格樣式
|
||||
Modify Table Style 修改表格樣式
|
||||
New Table Style… 新增表格樣式…
|
||||
Modify Table Style… 修改表格樣式…
|
||||
Apply formatting to: 將格式套用至:
|
||||
Whole Table 整個表格
|
||||
Fill color: 填滿色彩:
|
||||
{count} items highlighted 已醒目提示 {count} 個項目
|
||||
{n} pt {n} 點
|
||||
Not {name} 非{name}
|
||||
Level {n} 第 {n} 階
|
||||
Body Text 本文
|
||||
{n} lines {n} 行
|
||||
At least {n} 最小行高 {n}
|
||||
Exactly {n} 固定行高 {n}
|
||||
Centered 置中
|
||||
Character spacing 字元間距
|
||||
Don't add space between paragraphs of the same style 相同樣式的段落之間不新增間距
|
||||
Don't hyphenate 不斷字
|
||||
Double strikethrough 雙刪除線
|
||||
Drop cap 首字放大
|
||||
First line 第一行
|
||||
Font color 字型色彩
|
||||
Highlight 醒目提示
|
||||
Justified 左右對齊
|
||||
Left indent 左縮排
|
||||
Line spacing 行距
|
||||
Outline level 大綱階層
|
||||
Raised/lowered 上移/下移
|
||||
Right indent 右縮排
|
||||
Scale 縮放
|
||||
Space after 段落後間距
|
||||
Space before 段落前間距
|
||||
Suppress line numbers 隱藏行號
|
||||
Underline color 底線色彩
|
||||
Letters 信函
|
||||
E-mail Messages 電子郵件
|
||||
Directory 目錄
|
||||
|
|
@ -950,3 +1032,25 @@
|
|||
Offset: Top Right 位移:右上
|
||||
Offset: Top 位移:上
|
||||
Offset: Top Left 位移:左上
|
||||
Manage Styles 管理樣式
|
||||
Show or Hide Style 顯示或隱藏樣式
|
||||
Manage Styles… 管理樣式…
|
||||
Sort order: 排序方式:
|
||||
As Recommended 依建議
|
||||
Alphabetical 依字母順序
|
||||
Font: {font} 字型:{font}
|
||||
{size} pt {size} 點
|
||||
Color: {color} 色彩:{color}
|
||||
Space before: {pt} pt 段落前間距:{pt} 點
|
||||
Space after: {pt} pt 段落後間距:{pt} 點
|
||||
Based on: {style} 根據:{style}
|
||||
Paragraph style 段落樣式
|
||||
Character style 字元樣式
|
||||
Linked style (paragraph and character) 連結樣式(段落和字元)
|
||||
Table style 表格樣式
|
||||
Built-in style 內建樣式
|
||||
Show in the Styles gallery 在樣式庫中顯示
|
||||
Hide from style lists 從樣式清單中隱藏
|
||||
Built-in styles can't be deleted. 無法刪除內建樣式。
|
||||
Delete Table Style 刪除表格樣式
|
||||
Preview 預覽
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 4.
|
|
|
@ -868,6 +868,14 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
|
|||
pen.line(&[(6.0, 8.5), (11.0, 8.5)]);
|
||||
pen.line(&[(8.5, 6.0), (8.5, 11.0)]);
|
||||
}
|
||||
"zoomIn" | "zoomOut" => {
|
||||
pen.circle(8.5, 8.5, 5.5, c);
|
||||
pen.line_c(&[(12.5, 12.5), (17.5, 17.5)], a);
|
||||
pen.line(&[(6.0, 8.5), (11.0, 8.5)]);
|
||||
if name == "zoomIn" {
|
||||
pen.line(&[(8.5, 6.0), (8.5, 11.0)]);
|
||||
}
|
||||
}
|
||||
"zoom100" => pen.text(10.0, 10.0, 7.5, "100", a, true),
|
||||
"onePage" => pen.page(5.0, 2.0, 15.0, 18.0),
|
||||
"multiplePages" => {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ pub fn key_name(k: Key) -> Option<&'static str> {
|
|||
Key::F3 => "F3",
|
||||
Key::F5 => "F5",
|
||||
Key::F7 => "F7",
|
||||
Key::F8 => "F8",
|
||||
Key::F9 => "F9",
|
||||
Key::F12 => "F12",
|
||||
Key::A => "A",
|
||||
|
|
@ -132,6 +133,13 @@ fn equation_key(app: &mut WordApp, key: Key, m: Modifiers) -> bool {
|
|||
true
|
||||
}
|
||||
|
||||
/// Whether releasing `key` with `modifiers` is the plain Paste shortcut (Mod+V or the Paste
|
||||
/// key) — not Mod+Shift+V (Paste Formatting) or Mod+Alt+V (Paste Special) — so a clipboard
|
||||
/// picture may be pasted on it (#45).
|
||||
fn is_paste_release(key: Key, modifiers: egui::Modifiers) -> bool {
|
||||
(modifiers.command || key == Key::Paste) && !modifiers.alt && !modifiers.shift
|
||||
}
|
||||
|
||||
/// Events for the focused canvas: text, editing keys, clipboard, IME. While keytips are showing
|
||||
/// every key belongs to them (letters pick badges, Escape cancels), so nothing here runs.
|
||||
pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) {
|
||||
|
|
@ -140,6 +148,24 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) {
|
|||
}
|
||||
let events = ctx.input(|i| i.events.clone());
|
||||
for e in events {
|
||||
if let egui::Event::Paste(t) = &e {
|
||||
app.canvas.pasted = true;
|
||||
// Copied picture files arrive as their paths: insert the pictures (#45).
|
||||
if app.session.math.is_none() && !ctx.input(|i| i.modifiers.alt) && crate::paste_picture::paste_named_files(app, t) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Desktop: Mod+V with no text on the clipboard sends no paste event, only the key's
|
||||
// release; paste the clipboard's picture then (#45). A paste event before the release
|
||||
// means it was text. (The web reads pasted pictures itself.)
|
||||
if let egui::Event::Key { key: key @ (Key::V | Key::Paste), pressed: false, modifiers, .. } = &e {
|
||||
let paste_keys = is_paste_release(*key, *modifiers);
|
||||
let pasted = std::mem::take(&mut app.canvas.pasted);
|
||||
if !pasted && paste_keys && app.session.math.is_none() && app.services.clipboard_picture.is_some() {
|
||||
let _ = app.run("edit.paste", json!({}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Editing an equation: text and editing keys go into it.
|
||||
if app.session.math.is_some() {
|
||||
match &e {
|
||||
|
|
@ -185,7 +211,13 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) {
|
|||
}
|
||||
}
|
||||
egui::Event::Paste(t) => {
|
||||
let id = if ctx.input(|i| i.modifiers.shift && i.modifiers.alt) { "edit.pasteText" } else { "edit.paste" };
|
||||
// Mod+Alt+V arrives as a paste too (the shell reads the clipboard): Paste Special.
|
||||
let m = ctx.input(|i| i.modifiers);
|
||||
let id = match (m.alt, m.shift) {
|
||||
(true, true) => "edit.pasteText",
|
||||
(true, false) => "edit.pasteSpecial",
|
||||
_ => "edit.paste",
|
||||
};
|
||||
let _ = app.run(id, json!({"text": t}));
|
||||
}
|
||||
egui::Event::Copy | egui::Event::Cut => {
|
||||
|
|
@ -240,3 +272,18 @@ pub fn global_shortcuts(app: &mut WordApp, ctx: &egui::Context) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_plain_paste_releases_paste_a_clipboard_picture() {
|
||||
let m = |command, shift, alt| egui::Modifiers { command, shift, alt, ..Default::default() };
|
||||
assert!(is_paste_release(Key::V, m(true, false, false)), "Mod+V");
|
||||
assert!(is_paste_release(Key::Paste, m(false, false, false)), "the Paste key");
|
||||
assert!(!is_paste_release(Key::V, m(true, true, false)), "Mod+Shift+V is Paste Formatting");
|
||||
assert!(!is_paste_release(Key::V, m(true, false, true)), "Mod+Alt+V is Paste Special");
|
||||
assert!(!is_paste_release(Key::V, m(false, false, false)), "a plain V");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -333,7 +333,7 @@ fn controls(tab: &str) -> Vec<Control> {
|
|||
}
|
||||
"Table Layout" => vec![
|
||||
m("Select", "table.selectCell"),
|
||||
c("View Gridlines", "view.gridlines"),
|
||||
c("View Gridlines", "table.viewGridlines"),
|
||||
c("Properties", "table.properties"),
|
||||
m("Delete", "table.deleteCells"),
|
||||
c("Insert Above", "table.insertRowAbove"),
|
||||
|
|
@ -590,20 +590,36 @@ pub fn logic(app: &mut WordApp, ctx: &egui::Context) {
|
|||
let mut alt_released = false;
|
||||
let mut f10 = false;
|
||||
for e in &events {
|
||||
if let egui::Event::Key { key, pressed, modifiers, .. } = e {
|
||||
if is_alt(*key) {
|
||||
if *pressed && modifiers.any() {
|
||||
// Alt combined with another modifier is a shortcut, not a keytip chord.
|
||||
match e {
|
||||
egui::Event::Key { key, pressed, repeat, modifiers, .. } => {
|
||||
if is_alt(*key) {
|
||||
if *pressed {
|
||||
// A new Alt press starts a fresh chord (auto-repeat keeps the current one).
|
||||
if !*repeat {
|
||||
app.ui.alt_chord_used = false;
|
||||
}
|
||||
if modifiers.any() {
|
||||
// Alt combined with another modifier is a shortcut, not a keytip chord.
|
||||
other_key = true;
|
||||
}
|
||||
} else if !modifiers.any() {
|
||||
// Alt+click and Alt+drag (column selection), or Alt+key held across frames,
|
||||
// end with a bare Alt release too; only an untouched Alt tap counts.
|
||||
alt_released = !std::mem::take(&mut app.ui.alt_chord_used);
|
||||
}
|
||||
} else if *pressed {
|
||||
other_key = true;
|
||||
} else if !*pressed && !modifiers.any() {
|
||||
alt_released = true;
|
||||
}
|
||||
} else if *pressed {
|
||||
other_key = true;
|
||||
if *key == egui::Key::F10 && !modifiers.any() {
|
||||
f10 = true;
|
||||
app.ui.alt_chord_used = true;
|
||||
if *key == egui::Key::F10 && !modifiers.any() {
|
||||
f10 = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
egui::Event::PointerButton { pressed: true, .. } => {
|
||||
other_key = true;
|
||||
app.ui.alt_chord_used = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Route letter/navigation keys to the keytips while they are showing.
|
||||
|
|
@ -739,6 +755,40 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// Alt+drag selects a column (#237); releasing Alt afterwards must not open keytips. A bare
|
||||
/// Alt tap still does (off macOS, where Option types accents).
|
||||
#[test]
|
||||
fn alt_click_or_drag_does_not_arm_keytips_on_release() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut a = app();
|
||||
a.ui.backstage = false;
|
||||
a.dialog = None;
|
||||
let alt = egui::Modifiers { alt: true, ..Default::default() };
|
||||
let key = |pressed, modifiers| egui::Event::Key { key: egui::Key::AltLeft, physical_key: None, pressed, repeat: false, modifiers };
|
||||
let pos = egui::pos2(400.0, 300.0);
|
||||
let frame = |a: &mut WordApp, events: Vec<egui::Event>| {
|
||||
let input = egui::RawInput { events, ..Default::default() };
|
||||
ctx.run_ui(input, |ui| logic(a, ui.ctx())).drop_without_applying_deltas();
|
||||
};
|
||||
// Alt down, drag with the mouse over several frames, Alt up.
|
||||
frame(&mut a, vec![key(true, alt)]);
|
||||
frame(&mut a, vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: alt }]);
|
||||
frame(&mut a, vec![egui::Event::PointerMoved(pos + egui::vec2(60.0, 40.0))]);
|
||||
frame(&mut a, vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: alt }]);
|
||||
frame(&mut a, vec![key(false, egui::Modifiers::NONE)]);
|
||||
assert_eq!(a.ui.keytips, Phase::Off, "Alt+drag then Alt up leaves keytips off");
|
||||
// Alt held while a key is pressed in a later frame: also a chord.
|
||||
frame(&mut a, vec![key(true, alt)]);
|
||||
frame(&mut a, vec![egui::Event::Key { key: egui::Key::ArrowDown, physical_key: None, pressed: true, repeat: false, modifiers: alt }]);
|
||||
frame(&mut a, vec![key(false, egui::Modifiers::NONE)]);
|
||||
assert_eq!(a.ui.keytips, Phase::Off, "Alt+key then Alt up leaves keytips off");
|
||||
// A bare tap (press and release in separate frames) arms them.
|
||||
frame(&mut a, vec![key(true, alt)]);
|
||||
frame(&mut a, vec![key(false, egui::Modifiers::NONE)]);
|
||||
let tap = if cfg!(target_os = "macos") { Phase::Off } else { Phase::Tabs };
|
||||
assert_eq!(a.ui.keytips, tap);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_state_machine() {
|
||||
let mut a = app();
|
||||
|
|
|
|||
|
|
@ -29,9 +29,11 @@ pub mod keytips;
|
|||
pub mod mini_toolbar;
|
||||
pub mod objects;
|
||||
pub mod panes;
|
||||
pub mod paste_picture;
|
||||
pub mod previews;
|
||||
pub mod read_aloud;
|
||||
pub mod ribbon;
|
||||
pub mod scroll;
|
||||
pub mod theme;
|
||||
pub mod widgets;
|
||||
pub mod window_geometry;
|
||||
|
|
@ -69,6 +71,9 @@ pub struct Services {
|
|||
/// Web: hand PDF bytes to the browser's print flow directly (no download, no intermediate
|
||||
/// file) — opens the system print dialog on that PDF. An error means no dialog was opened.
|
||||
pub print: Option<Box<dyn Fn(&[u8]) -> Result<(), String>>>,
|
||||
/// Desktop: the picture on the system clipboard, if any — egui's paste only carries text
|
||||
/// (#45). Read when Paste finds no text; see [`paste_picture`].
|
||||
pub clipboard_picture: Option<Box<dyn Fn() -> Option<paste_picture::ClipboardPicture>>>,
|
||||
}
|
||||
|
||||
/// Files delivered asynchronously.
|
||||
|
|
@ -104,6 +109,10 @@ pub struct UiState {
|
|||
/// Keytips (Alt) state; not persisted, it resets each session.
|
||||
#[serde(skip)]
|
||||
pub keytips: crate::keytips::Phase,
|
||||
/// Since the last Alt press, another key or a mouse button was pressed: the Alt release
|
||||
/// that follows ends a chord (Alt+click, Alt+drag column selection), not a keytip tap.
|
||||
#[serde(skip)]
|
||||
pub alt_chord_used: bool,
|
||||
/// View › Switch Modes: show pages dark (white text on black), kept between runs.
|
||||
pub dark_page: bool,
|
||||
}
|
||||
|
|
@ -127,6 +136,7 @@ impl Default for UiState {
|
|||
read_aloud_rate: 1.0,
|
||||
read_aloud_skip_citations: true,
|
||||
keytips: crate::keytips::Phase::Off,
|
||||
alt_chord_used: false,
|
||||
dark_page: false,
|
||||
}
|
||||
}
|
||||
|
|
@ -187,6 +197,10 @@ pub struct WordApp {
|
|||
autosave_path: Option<std::path::PathBuf>,
|
||||
/// The file dialog the host is showing, and what its answer is for ([`file_dialogs`]).
|
||||
file_dialog: Option<file_dialogs::PendingDialog>,
|
||||
/// File › Info: the property field being typed in and the document revision after its last
|
||||
/// keystroke, so one visit's keystrokes make a single undo step (and anything else in
|
||||
/// between, such as an Undo, starts a new one).
|
||||
pub(crate) info_editing: Option<(&'static str, u64)>,
|
||||
}
|
||||
|
||||
/// The answer to "Do you want to save changes?" (`ui.saveChanges`).
|
||||
|
|
@ -246,6 +260,7 @@ impl WordApp {
|
|||
change_picture_target: None,
|
||||
recipient_list_pending: false,
|
||||
file_dialog: None,
|
||||
info_editing: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -302,6 +317,14 @@ impl WordApp {
|
|||
self.dialog = Some(dialogs::Dialog::SaveChanges { name, then: id.to_string(), params, document: self.session.document_id() });
|
||||
return Ok(json!({"pending": "saveChanges"}));
|
||||
}
|
||||
// Paste without system clipboard text: a picture there is newer than our own copy (copying
|
||||
// here replaces it with text), so it wins (#45).
|
||||
if id == "edit.paste"
|
||||
&& params.get("text").is_none()
|
||||
&& let Some(r) = paste_picture::paste(self)
|
||||
{
|
||||
return r;
|
||||
}
|
||||
let r = self.execute(id, params);
|
||||
// Match Fields and Check for Errors show what they found.
|
||||
if let Ok(v) = &r
|
||||
|
|
@ -336,6 +359,10 @@ impl WordApp {
|
|||
if self.services.download.is_some() && matches!(id, "file.save" | "file.saveAs" | "file.exportPdf" | "file.exportPng") {
|
||||
let name = params.get("path").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| self.default_save_name());
|
||||
let name = if id == "file.exportPdf" && !name.ends_with(".pdf") { format!("{name}.pdf") } else { name };
|
||||
// A save is a save, download or not: advance the revision and modified stamp (#262).
|
||||
if matches!(id, "file.save" | "file.saveAs") {
|
||||
self.session.stamp_save();
|
||||
}
|
||||
let bytes = wordcraft_engine::io::save_bytes(&name, &self.session.doc)?;
|
||||
if let Some(d) = &self.services.download
|
||||
&& let Err(e) = d(&name, &bytes)
|
||||
|
|
@ -475,6 +502,7 @@ impl WordApp {
|
|||
self.ui.backstage = true;
|
||||
self.ui.backstage_page = "options".into();
|
||||
}
|
||||
"pasteSpecial" => self.dialog = Some(dialogs::Dialog::paste_special(self, req)),
|
||||
other => self.dialog = dialogs::Dialog::open(other, self),
|
||||
}
|
||||
}
|
||||
|
|
@ -1076,6 +1104,91 @@ mod tests {
|
|||
assert!(a.canvas.scale < zoomed, "Ctrl+wheel down zooms out");
|
||||
}
|
||||
|
||||
/// Issue #67: View › Zoom In from a fit mode zoomed *out*: Page Width showed 163% but Zoom In
|
||||
/// stepped from the stale manual 100% to 110%.
|
||||
#[test]
|
||||
fn view_tab_zoom_steps_from_the_shown_zoom_in_fit_modes() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut a = app();
|
||||
a.run("ui.tab", json!({"tab": "View"})).unwrap();
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0));
|
||||
let frame = |a: &mut WordApp| {
|
||||
for _ in 0..4 {
|
||||
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
|
||||
ctx.run_ui(input, |ui| {
|
||||
a.logic(ui.ctx());
|
||||
a.ui(ui);
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
}
|
||||
a.canvas.scale / canvas::PX_PER_PT
|
||||
};
|
||||
let base = frame(&mut a);
|
||||
assert!((base - 1.0).abs() < 1e-3);
|
||||
for fit in ["view.pageWidth", "view.onePage", "view.multiplePages"] {
|
||||
a.run(fit, json!({})).unwrap();
|
||||
let fitted = frame(&mut a);
|
||||
assert!((fitted - base).abs() > 0.05, "{fit} changes the zoom");
|
||||
assert!((a.session.view.zoom - fitted).abs() < 1e-3, "{fit}: session zoom follows the shown zoom");
|
||||
a.run("view.zoomIn", json!({})).unwrap();
|
||||
let zin = frame(&mut a);
|
||||
assert!(zin > fitted, "{fit}: Zoom In zooms in: {fitted} -> {zin}");
|
||||
a.run(fit, json!({})).unwrap();
|
||||
frame(&mut a);
|
||||
a.run("view.zoomOut", json!({})).unwrap();
|
||||
let zout = frame(&mut a);
|
||||
assert!(zout < fitted, "{fit}: Zoom Out zooms out: {fitted} -> {zout}");
|
||||
// The Zoom dialog opens at the shown zoom.
|
||||
a.run(fit, json!({})).unwrap();
|
||||
frame(&mut a);
|
||||
a.run("ui.dialog", json!({"name": "zoom"})).unwrap();
|
||||
assert!(matches!(a.dialog, Some(dialogs::Dialog::Zoom { percent }) if (percent - (fitted * 100.0).round()).abs() < 1.0));
|
||||
a.dialog = None;
|
||||
}
|
||||
a.run("view.zoom100", json!({})).unwrap();
|
||||
assert!((frame(&mut a) - 1.0).abs() < 1e-3);
|
||||
}
|
||||
|
||||
/// Issue #122: touchpad deltas scroll the page 1:1 at once; a wheel notch eases in to about
|
||||
/// three lines.
|
||||
#[test]
|
||||
fn touchpad_scrolls_one_to_one_and_wheel_notches_ease_in() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut a = app();
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 600.0));
|
||||
let mut t = 0.0;
|
||||
let mut frame = |a: &mut WordApp, events: Vec<egui::Event>| {
|
||||
t += 1.0 / 60.0;
|
||||
let input = egui::RawInput { events, time: Some(t), screen_rect: Some(screen), ..Default::default() };
|
||||
ctx.run_ui(input, |ui| {
|
||||
a.logic(ui.ctx());
|
||||
a.ui(ui);
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
};
|
||||
for _ in 0..3 {
|
||||
frame(&mut a, Vec::new());
|
||||
}
|
||||
let over_page = a.canvas.canvas_rect.unwrap().center();
|
||||
frame(&mut a, vec![egui::Event::PointerMoved(over_page)]);
|
||||
let wheel = |unit, y: f32, phase| egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, y), phase, modifiers: egui::Modifiers::NONE };
|
||||
let start = a.canvas.scroll_offset.y;
|
||||
frame(&mut a, vec![wheel(egui::MouseWheelUnit::Point, -40.0, egui::TouchPhase::Start)]);
|
||||
assert_eq!(a.canvas.scroll_offset.y, start + 40.0, "the first touchpad delta lands in the same frame");
|
||||
// Fingers lift; the coast after it is left to the unit tests (crate::scroll).
|
||||
frame(&mut a, vec![wheel(egui::MouseWheelUnit::Point, 0.0, egui::TouchPhase::End)]);
|
||||
a.canvas.wheel = Default::default();
|
||||
let at = a.canvas.scroll_offset.y;
|
||||
frame(&mut a, vec![wheel(egui::MouseWheelUnit::Line, -1.0, egui::TouchPhase::Move)]);
|
||||
let first = a.canvas.scroll_offset.y - at;
|
||||
for _ in 0..30 {
|
||||
frame(&mut a, Vec::new());
|
||||
}
|
||||
let notch = crate::scroll::notch_px(a.canvas.scale / crate::canvas::PX_PER_PT);
|
||||
assert!(first > 0.0 && first < notch, "a notch eases in: {first}");
|
||||
assert!((a.canvas.scroll_offset.y - at - notch).abs() < 0.5, "a notch scrolls {notch}: {}", a.canvas.scroll_offset.y - at);
|
||||
}
|
||||
|
||||
/// Issue #123: zoomed out, pages sit side by side, and clicks map to the page under the pointer.
|
||||
#[test]
|
||||
fn zoomed_out_pages_sit_side_by_side_and_clicks_land_on_them() {
|
||||
|
|
|
|||
|
|
@ -86,6 +86,12 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
|
|||
return false;
|
||||
}
|
||||
let Some((object, grab)) = grab_at(app, layout, pages, scale, at) else { return false };
|
||||
// Shift/Ctrl+click adds an object to the selected ones (or takes it out), for Group.
|
||||
let adding = ui.input(|i| i.modifiers.shift || i.modifiers.command);
|
||||
if pressed && adding && grab == Grab::Move && selected(app).is_some() {
|
||||
let _ = app.run("select.addObject", json!({"pos": object.pos()}));
|
||||
return true;
|
||||
}
|
||||
if pressed {
|
||||
let pos = object.pos();
|
||||
let end = Pos { off: pos.off + OBJ.len_utf8(), ..pos.clone() };
|
||||
|
|
@ -153,6 +159,12 @@ pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, p
|
|||
frame::paint(painter, t, to, false, false);
|
||||
return;
|
||||
}
|
||||
// The others selected along with it (Shift+click): their outlines.
|
||||
for p in &app.session.also_selected {
|
||||
if let Some(f) = layout.object(p, app.session.page_hint).and_then(|o| screen(pages, scale, o.page, o.rect)) {
|
||||
frame::paint(painter, t, f, false, false);
|
||||
}
|
||||
}
|
||||
if let Some((o, editing)) = active(app, layout)
|
||||
&& let Some(f) = screen(pages, scale, o.page, o.rect)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! Side panes: Navigation (headings, pages, search results), Styles, Comments.
|
||||
//! Side panes: Navigation (headings, pages, search results), Clipboard, Styles, Style Inspector, Comments.
|
||||
|
||||
use egui::{Stroke, Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -16,6 +16,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
|
||||
.show(ui, |ui| nav(app, ui));
|
||||
}
|
||||
if app.session.view.clipboard_pane {
|
||||
egui::Panel::left("clipboard_pane")
|
||||
.default_size(250.0)
|
||||
.resizable(true)
|
||||
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
|
||||
.show(ui, |ui| clipboard(app, ui));
|
||||
}
|
||||
if app.session.view.styles_pane {
|
||||
egui::Panel::right("styles_pane")
|
||||
.default_size(250.0)
|
||||
|
|
@ -23,6 +30,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
|
||||
.show(ui, |ui| styles(app, ui));
|
||||
}
|
||||
if app.session.view.style_inspector {
|
||||
egui::Panel::right("style_inspector")
|
||||
.default_size(240.0)
|
||||
.resizable(true)
|
||||
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
|
||||
.show(ui, |ui| inspector(app, ui));
|
||||
}
|
||||
if app.session.view.comments_pane {
|
||||
egui::Panel::right("comments_pane")
|
||||
.default_size(290.0)
|
||||
|
|
@ -138,6 +152,51 @@ fn nav(app: &mut WordApp, ui: &mut Ui) {
|
|||
});
|
||||
}
|
||||
|
||||
/// Items collected by Copy and Cut, newest first; clicking one pastes it at the caret.
|
||||
fn clipboard(app: &mut WordApp, ui: &mut Ui) {
|
||||
if header(ui, "Clipboard") {
|
||||
let _ = app.run("edit.clipboardPane", json!({"value": false}));
|
||||
return;
|
||||
}
|
||||
let items: Vec<String> = app.session.clip_history.items().iter().map(|i| i.preview()).collect();
|
||||
ui.horizontal(|ui| {
|
||||
if ui.add_enabled(!items.is_empty(), egui::Button::new(tl!("Paste All"))).clicked() {
|
||||
let _ = app.run("edit.pasteAllClipboard", json!({}));
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
if ui.add_enabled(!items.is_empty(), egui::Button::new(tl!("Clear All"))).clicked() {
|
||||
let _ = app.run("edit.clearClipboard", json!({}));
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let hint = if items.is_empty() { "Nothing collected yet. Items you copy or cut appear here." } else { "Click an item to paste it." };
|
||||
ui.label(egui::RichText::new(tl!(hint)).small().weak());
|
||||
ui.separator();
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
for (i, preview) in items.iter().enumerate() {
|
||||
egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.border)).corner_radius(6).inner_margin(8).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
let text = if preview.is_empty() { tl!("(picture or object)").to_string() } else { preview.clone() };
|
||||
let mut job =
|
||||
egui::text::LayoutJob::single_section(text, egui::TextFormat { font_id: regular(12.5), color: t.text, ..Default::default() });
|
||||
job.wrap.max_width = ui.available_width();
|
||||
job.wrap.max_rows = 3;
|
||||
if ui.add(egui::Button::new(job).frame(false)).on_hover_text(tl!("Paste")).clicked() {
|
||||
let _ = app.run("edit.pasteClipboardItem", json!({"index": i}));
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
|
||||
if ui.small_button(tl!("Delete")).clicked() {
|
||||
let _ = app.run("edit.deleteClipboardItem", json!({"index": i}));
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn styles(app: &mut WordApp, ui: &mut Ui) {
|
||||
if header(ui, "Styles") {
|
||||
let _ = app.run("view.stylesPane", json!({"value": false}));
|
||||
|
|
@ -160,6 +219,12 @@ fn styles(app: &mut WordApp, ui: &mut Ui) {
|
|||
let _ = app.run("format.clear", json!({}));
|
||||
}
|
||||
});
|
||||
if ui.button(tl!("Manage Styles…")).clicked() {
|
||||
let _ = app.run("styles.manage", json!({}));
|
||||
}
|
||||
if ui.selectable_label(app.session.view.style_inspector, tl!("Style Inspector")).clicked() {
|
||||
let _ = app.run("styles.inspector", json!({}));
|
||||
}
|
||||
ui.separator();
|
||||
let mut list: Vec<(String, String, bool)> = app
|
||||
.session
|
||||
|
|
@ -264,3 +329,222 @@ fn comments(app: &mut WordApp, ui: &mut Ui) {
|
|||
});
|
||||
let _ = vec2(0.0, 0.0);
|
||||
}
|
||||
|
||||
/// Style Inspector: the paragraph and character levels at the caret, each with its style and the
|
||||
/// direct formatting on top, and a button to clear each level.
|
||||
fn inspector(app: &mut WordApp, ui: &mut Ui) {
|
||||
if header(ui, "Style Inspector") {
|
||||
let _ = app.run("styles.inspector", json!({"value": false}));
|
||||
return;
|
||||
}
|
||||
// Read-only, so call it directly: running the command every frame would pay for the command
|
||||
// machinery and consume per-command state (a pending `join_next_undo`) just to draw the pane.
|
||||
let r = wordcraft_engine::cmd::inspector::inspect(&app.session);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let none = tl!("None").to_string();
|
||||
let para = &r["paragraph"];
|
||||
let chr = &r["character"];
|
||||
let pstyle = para["styleName"].as_str().unwrap_or("Normal").to_string();
|
||||
let cstyle = chr["styleName"].as_str().map(str::to_string);
|
||||
let pdirect = direct_list(¶["direct"]);
|
||||
let cdirect = direct_list(&chr["direct"]);
|
||||
let mut clear: Option<&str> = None;
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
section(ui, &t, "Paragraph", |ui| {
|
||||
if level_row(ui, &t, "Style", &pstyle, false, "Reset to Normal", pstyle == "Normal" && para["style"] == "Normal") {
|
||||
clear = Some("paragraphStyle");
|
||||
}
|
||||
let (txt, empty) = if pdirect.is_empty() { (none.clone(), true) } else { (pdirect.join(", "), false) };
|
||||
if level_row(ui, &t, "Direct formatting", &txt, empty, "Clear Paragraph Formatting", empty) {
|
||||
clear = Some("paragraphFormatting");
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
section(ui, &t, "Characters", |ui| {
|
||||
let (txt, empty) = match &cstyle {
|
||||
Some(n) => (n.clone(), false),
|
||||
None => (none.clone(), true),
|
||||
};
|
||||
if level_row(ui, &t, "Style", &txt, empty, "Clear Character Style", empty) {
|
||||
clear = Some("characterStyle");
|
||||
}
|
||||
let (txt, empty) = if cdirect.is_empty() { (none.clone(), true) } else { (cdirect.join(", "), false) };
|
||||
if level_row(ui, &t, "Direct formatting", &txt, empty, "Clear Character Formatting", empty) {
|
||||
clear = Some("characterFormatting");
|
||||
}
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(tl!("Clear All")).on_hover_text(tl!("Clear All Formatting")).clicked() {
|
||||
let _ = app.run("format.clear", json!({}));
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
if ui.button(tl!("Styles Pane")).clicked() {
|
||||
let _ = app.run("view.stylesPane", json!({"value": true}));
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some(level) = clear {
|
||||
let _ = app.run("styles.inspectorClear", json!({"level": level}));
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// A titled, bordered group in the inspector.
|
||||
fn section(ui: &mut Ui, t: &Tokens, title: &str, body: impl FnOnce(&mut Ui)) {
|
||||
ui.label(egui::RichText::new(tl!(title)).font(semibold(12.5)).color(t.text));
|
||||
ui.add_space(2.0);
|
||||
egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.border)).corner_radius(4).inner_margin(8).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
body(ui);
|
||||
});
|
||||
}
|
||||
|
||||
/// One level: a caption, its value, and a clear button (disabled when there's nothing to clear).
|
||||
/// Returns true when the button was clicked.
|
||||
fn level_row(ui: &mut Ui, t: &Tokens, caption: &str, value: &str, dim: bool, tip: &str, disabled: bool) -> bool {
|
||||
ui.label(egui::RichText::new(tl!(caption)).font(regular(11.0)).color(t.text_dim));
|
||||
let mut clicked = false;
|
||||
ui.horizontal(|ui| {
|
||||
let w = (ui.available_width() - 26.0).max(40.0);
|
||||
ui.allocate_ui(vec2(w, 18.0), |ui| {
|
||||
ui.set_width(w);
|
||||
let text = egui::RichText::new(value).font(regular(12.5)).color(if dim { t.text_disabled } else { t.text });
|
||||
ui.add(egui::Label::new(text).wrap());
|
||||
});
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 20.0), egui::Sense::click());
|
||||
let enabled = !disabled;
|
||||
if enabled && resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
let c = if enabled { t.icon } else { t.text_disabled };
|
||||
crate::icons::paint(
|
||||
ui.painter(),
|
||||
egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)),
|
||||
"clear",
|
||||
c,
|
||||
if enabled { t.accent } else { c },
|
||||
);
|
||||
let resp = resp.on_hover_text(tl!(tip));
|
||||
clicked = enabled && resp.clicked();
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
clicked
|
||||
}
|
||||
|
||||
/// Readable descriptions of the inspector's `direct` entries (`[{"prop", "value"}]`).
|
||||
fn direct_list(v: &Value) -> Vec<String> {
|
||||
v.as_array().map(|a| a.iter().take(64).map(|d| describe(d["prop"].as_str().unwrap_or(""), &d["value"])).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// "Bold", "Not Italic", "Font: Arial", "Size: 14 pt"…
|
||||
fn describe(prop: &str, v: &Value) -> String {
|
||||
let label = match prop {
|
||||
"font" => "Font",
|
||||
"size" => "Size",
|
||||
"bold" => "Bold",
|
||||
"italic" => "Italic",
|
||||
"underline" => "Underline",
|
||||
"underlineColor" => "Underline color",
|
||||
"strike" => "Strikethrough",
|
||||
"doubleStrike" => "Double strikethrough",
|
||||
"color" => "Font color",
|
||||
"highlight" => "Highlight",
|
||||
"shading" => "Shading",
|
||||
"vertAlign" => "Position",
|
||||
"caps" => "All caps",
|
||||
"smallCaps" => "Small caps",
|
||||
"hidden" => "Hidden",
|
||||
"spacing" => "Character spacing",
|
||||
"scale" => "Scale",
|
||||
"position" => "Raised/lowered",
|
||||
"outline" => "Outline",
|
||||
"shadow" => "Shadow",
|
||||
"emboss" => "Emboss",
|
||||
"engrave" => "Engrave",
|
||||
"align" => "Alignment",
|
||||
"indentLeft" => "Left indent",
|
||||
"indentRight" => "Right indent",
|
||||
"indentFirst" => "First line",
|
||||
"spaceBefore" => "Space before",
|
||||
"spaceAfter" => "Space after",
|
||||
"lineSpacing" => "Line spacing",
|
||||
"contextualSpacing" => "Don't add space between paragraphs of the same style",
|
||||
"keepNext" => "Keep with next",
|
||||
"keepLines" => "Keep lines together",
|
||||
"pageBreakBefore" => "Page break before",
|
||||
"widowControl" => "Widow/Orphan control",
|
||||
"outlineLevel" => "Outline level",
|
||||
"tabs" => "Tabs",
|
||||
"borders" => "Borders",
|
||||
"suppressHyphens" => "Don't hyphenate",
|
||||
"suppressLineNumbers" => "Suppress line numbers",
|
||||
"bidi" => "Right-to-left",
|
||||
"dropCap" => "Drop cap",
|
||||
other => other,
|
||||
};
|
||||
let label = tl!(label);
|
||||
let pt = |x: f64| crate::i18n::fmt(tl!("{n} pt"), &[("n", &trim_num(x))]);
|
||||
let hex = |c: &Value| -> Option<String> {
|
||||
let a = c.as_array()?;
|
||||
let ch = |i: usize| a.get(i).and_then(Value::as_u64).unwrap_or(0).min(255);
|
||||
Some(format!("#{:02X}{:02X}{:02X}", ch(0), ch(1), ch(2)))
|
||||
};
|
||||
let value = match (prop, v) {
|
||||
(_, Value::Bool(true)) => return label.to_string(),
|
||||
(_, Value::Bool(false)) => return crate::i18n::fmt(tl!("Not {name}"), &[("name", label)]),
|
||||
("scale", Value::Number(n)) => format!("{}%", trim_num(n.as_f64().unwrap_or(100.0))),
|
||||
("outlineLevel", Value::Number(n)) => match n.as_u64().unwrap_or(9) {
|
||||
l @ 0..=8 => crate::i18n::fmt(tl!("Level {n}"), &[("n", &(l + 1).to_string())]),
|
||||
_ => tl!("Body Text").to_string(),
|
||||
},
|
||||
("dropCap", Value::Number(n)) => crate::i18n::fmt(tl!("{n} lines"), &[("n", &n.to_string())]),
|
||||
(_, Value::Number(n)) => pt(n.as_f64().unwrap_or(0.0)),
|
||||
("align", Value::String(s)) => tl!(match s.as_str() {
|
||||
"center" => "Centered",
|
||||
"right" => "Right",
|
||||
"justify" => "Justified",
|
||||
"distribute" => "Distributed",
|
||||
_ => "Left",
|
||||
})
|
||||
.to_string(),
|
||||
("color", Value::String(_)) => tl!("Automatic").to_string(),
|
||||
("color", Value::Object(o)) => o.get("Rgb").and_then(hex).unwrap_or_default(),
|
||||
(_, Value::Array(_)) if prop != "tabs" => hex(v).unwrap_or_default(),
|
||||
("lineSpacing", Value::Object(o)) => {
|
||||
let x = o.get("value").and_then(Value::as_f64).unwrap_or(1.0);
|
||||
match o.get("rule").and_then(Value::as_str) {
|
||||
Some("atLeast") => crate::i18n::fmt(tl!("At least {n}"), &[("n", &pt(x))]),
|
||||
Some("exactly") => crate::i18n::fmt(tl!("Exactly {n}"), &[("n", &pt(x))]),
|
||||
_ => crate::i18n::fmt(tl!("{n} lines"), &[("n", &trim_num(x))]),
|
||||
}
|
||||
}
|
||||
(_, Value::String(s)) => tl!(&camel_words(s)).to_string(),
|
||||
_ => return label.to_string(),
|
||||
};
|
||||
format!("{label}: {value}")
|
||||
}
|
||||
|
||||
/// `1.50` → `1.5`, `12.0` → `12`.
|
||||
fn trim_num(x: f64) -> String {
|
||||
let s = format!("{:.2}", if x.is_finite() { x } else { 0.0 });
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
/// `dotDash` → `Dot dash` (enum values from the document model).
|
||||
fn camel_words(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 4);
|
||||
for (i, c) in s.chars().take(64).enumerate() {
|
||||
if i == 0 {
|
||||
out.extend(c.to_uppercase());
|
||||
} else if c.is_uppercase() {
|
||||
out.push(' ');
|
||||
out.extend(c.to_lowercase());
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
//! Pasting a picture from the system clipboard (#45).
|
||||
//!
|
||||
//! egui's paste event only carries text, so a clipboard holding just a picture (a screenshot, a
|
||||
//! browser's Copy Image) or a copied picture file used to paste nothing. The host reads the
|
||||
//! clipboard through [`crate::Services::clipboard_picture`]; this module checks what it got — the
|
||||
//! clipboard comes from other programs, so sizes are hostile — and inserts it with
|
||||
//! `insert.picture`, one undoable step like any inserted picture.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::WordApp;
|
||||
|
||||
/// What the host found on the system clipboard.
|
||||
pub enum ClipboardPicture {
|
||||
/// Straight (not premultiplied) RGBA8 pixels, row by row.
|
||||
Pixels { width: usize, height: usize, rgba: Vec<u8> },
|
||||
/// Copied files (a file manager's Copy); the pictures among them are inserted.
|
||||
Files(Vec<PathBuf>),
|
||||
}
|
||||
|
||||
/// Longest side of a pasted picture, in pixels.
|
||||
pub const MAX_SIDE: usize = 16_000;
|
||||
/// Most pixels in a pasted picture (200 MB of RGBA).
|
||||
pub const MAX_PIXELS: usize = 50_000_000;
|
||||
/// Largest picture file pasted (as `insert.picture` allows).
|
||||
pub const MAX_FILE_BYTES: u64 = 200 << 20;
|
||||
/// Most picture files one paste inserts.
|
||||
const MAX_FILES: usize = 20;
|
||||
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];
|
||||
|
||||
/// PNG bytes for clipboard pixels, or why they can't be pasted.
|
||||
pub fn png_from_rgba(width: usize, height: usize, rgba: Vec<u8>) -> Result<Vec<u8>, String> {
|
||||
if width == 0 || height == 0 {
|
||||
return Err("the clipboard picture is empty".into());
|
||||
}
|
||||
let pixels = width.checked_mul(height).filter(|&n| width <= MAX_SIDE && height <= MAX_SIDE && n <= MAX_PIXELS);
|
||||
let Some(pixels) = pixels else {
|
||||
return Err(format!("the clipboard picture is too large to paste ({width} × {height} pixels)"));
|
||||
};
|
||||
if Some(rgba.len()) != pixels.checked_mul(4) {
|
||||
return Err("the clipboard picture is damaged (its size doesn't match its pixels)".into());
|
||||
}
|
||||
let (Ok(w), Ok(h)) = (u32::try_from(width), u32::try_from(height)) else {
|
||||
return Err("the clipboard picture is too large to paste".into());
|
||||
};
|
||||
let img = image::RgbaImage::from_raw(w, h, rgba).ok_or("the clipboard picture is damaged")?;
|
||||
let mut png = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| format!("couldn't read the clipboard picture: {e}"))?;
|
||||
Ok(png)
|
||||
}
|
||||
|
||||
fn is_picture_file(path: &Path) -> bool {
|
||||
path.extension().and_then(|e| e.to_str()).is_some_and(|e| IMAGE_EXTS.iter().any(|x| x.eq_ignore_ascii_case(e)))
|
||||
}
|
||||
|
||||
/// Pasted text that is only paths or `file://` addresses of picture files — what file managers
|
||||
/// put on the clipboard as text when files are copied.
|
||||
pub fn names_picture_files(text: &str) -> bool {
|
||||
if text.len() > 64 << 10 {
|
||||
return false;
|
||||
}
|
||||
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).peekable();
|
||||
lines.peek().is_some()
|
||||
&& lines.take(MAX_FILES + 1).enumerate().all(|(i, l)| {
|
||||
let path = l.strip_prefix("file://").unwrap_or(l);
|
||||
i < MAX_FILES && (l.starts_with("file://") || Path::new(path).is_absolute()) && is_picture_file(Path::new(path))
|
||||
})
|
||||
}
|
||||
|
||||
/// Paste the picture on the system clipboard. `None` when there is none (or no clipboard
|
||||
/// service): the caller pastes as usual.
|
||||
pub(crate) fn paste(app: &mut WordApp) -> Option<Result<Value, String>> {
|
||||
let picture = app.services.clipboard_picture.as_ref()?()?;
|
||||
let r = match picture {
|
||||
ClipboardPicture::Pixels { width, height, rgba } => match png_from_rgba(width, height, rgba) {
|
||||
Ok(png) => app.execute("insert.picture", json!({"data": wordcraft_engine::cmd::insert::base64_encode(&png)})),
|
||||
Err(e) => {
|
||||
log::warn!("clipboard picture: {e}");
|
||||
app.status(e.clone());
|
||||
Err(e)
|
||||
}
|
||||
},
|
||||
ClipboardPicture::Files(files) => {
|
||||
let files: Vec<PathBuf> = files.into_iter().filter(|p| is_picture_file(p)).take(MAX_FILES).collect();
|
||||
if files.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut r = Err(String::new());
|
||||
for path in files {
|
||||
let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
|
||||
r = if size > MAX_FILE_BYTES {
|
||||
let e = format!("{} is larger than 200 MB", path.display());
|
||||
app.status(e.clone());
|
||||
Err(e)
|
||||
} else {
|
||||
app.execute("insert.picture", json!({"path": path.to_string_lossy()}))
|
||||
};
|
||||
if r.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
};
|
||||
Some(r)
|
||||
}
|
||||
|
||||
/// Paste text naming copied picture files: insert the pictures when the clipboard holds those
|
||||
/// files. False leaves the text to the usual paste.
|
||||
pub(crate) fn paste_named_files(app: &mut WordApp, text: &str) -> bool {
|
||||
names_picture_files(text) && paste(app).is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
use wordcraft_engine::Session;
|
||||
|
||||
fn app_with(picture: impl Fn() -> Option<ClipboardPicture> + 'static) -> WordApp {
|
||||
let services = Services { clipboard_picture: Some(Box::new(picture)), ..Default::default() };
|
||||
WordApp::new(Session::new(wordcraft_doc::Document::new()), services)
|
||||
}
|
||||
|
||||
fn pictures(a: &mut WordApp) -> usize {
|
||||
a.session.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(|x| x.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Paste with only a picture on the clipboard inserts it at its natural size, one undo step.
|
||||
#[test]
|
||||
fn paste_inserts_clipboard_pixels() {
|
||||
let mut a = app_with(|| Some(ClipboardPicture::Pixels { width: 40, height: 20, rgba: vec![200; 40 * 20 * 4] }));
|
||||
let r = a.run("edit.paste", json!({})).unwrap();
|
||||
// 96 ppi: 40 × 20 px is 30 × 15 pt.
|
||||
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(30.0), Some(15.0)));
|
||||
assert_eq!(pictures(&mut a), 1);
|
||||
let media = a.session.doc.media.values().next().unwrap();
|
||||
assert_eq!(wordcraft_render::image_size(media), Some((40, 20)));
|
||||
a.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(pictures(&mut a), 0);
|
||||
// Pasted text that names picture files only becomes pictures when files were copied.
|
||||
assert!(names_picture_files("file:///home/me/shot%201.png\n/tmp/b.JPG\n"));
|
||||
assert!(!names_picture_files("see /tmp/b.png") && !names_picture_files("notes.txt") && !names_picture_files(""));
|
||||
}
|
||||
|
||||
/// Hostile clipboard sizes are refused without inserting anything (or allocating for them).
|
||||
#[test]
|
||||
fn hostile_clipboard_pictures_are_refused() {
|
||||
assert!(png_from_rgba(100_000, 1, vec![0; 400_000]).is_err());
|
||||
assert!(png_from_rgba(usize::MAX, 2, Vec::new()).is_err());
|
||||
assert!(png_from_rgba(10_000, 10_000, Vec::new()).is_err());
|
||||
assert!(png_from_rgba(4, 4, vec![0; 63]).is_err());
|
||||
assert!(png_from_rgba(0, 4, Vec::new()).is_err());
|
||||
let mut a = app_with(|| Some(ClipboardPicture::Pixels { width: 1 << 40, height: 1 << 40, rgba: Vec::new() }));
|
||||
assert!(a.run("edit.paste", json!({})).is_err());
|
||||
assert_eq!(pictures(&mut a), 0);
|
||||
// Copied files that aren't pictures leave Paste to the usual clipboard.
|
||||
let mut a = app_with(|| Some(ClipboardPicture::Files(vec![PathBuf::from("/tmp/notes.txt")])));
|
||||
assert_eq!(a.run("edit.paste", json!({})).unwrap_err(), "the clipboard is empty");
|
||||
}
|
||||
}
|
||||
|
|
@ -218,6 +218,55 @@ fn snippet_blocks(
|
|||
Some(egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &px))
|
||||
}
|
||||
|
||||
/// Manage Styles' preview of one style (`ty`: the `styles.manage` type), `size` points wide and
|
||||
/// tall: a line of sample text, or a small table for a table style.
|
||||
pub fn style_preview(app: &mut WordApp, ui: &mut Ui, id: &str, ty: &str, size: egui::Vec2) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(size, Sense::hover());
|
||||
ui.painter().rect(r, 3.0, egui::Color32::WHITE, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
let rev = app.session.rev();
|
||||
let skey = app.previews.styles_key(&app.session.doc, rev);
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let (w, h) = (size.x - 12.0, size.y - 8.0);
|
||||
let key = format!("manage:{id}:{ty}:{skey}:{ppp}:{w}x{h}");
|
||||
let doc = &app.session.doc;
|
||||
let tex = app.previews.get_or(ui.ctx(), &key, || {
|
||||
let block = match ty {
|
||||
"table" => {
|
||||
let mut tb = Table::new(3, 4, w - 4.0);
|
||||
tb.props.style = Some(id.to_string());
|
||||
for (ri, row) in tb.rows.iter_mut().enumerate() {
|
||||
for (ci, cell) in row.cells.iter_mut().enumerate() {
|
||||
let txt = match (ri, ci) {
|
||||
(0, c) => ["", "A", "B", "C"].get(c).copied().unwrap_or(""),
|
||||
(_, 0) => "Row",
|
||||
_ => "1",
|
||||
};
|
||||
cell.blocks = vec![para_block(Paragraph::with_text(txt, CharProps::default()))];
|
||||
}
|
||||
}
|
||||
Block::Table(tb)
|
||||
}
|
||||
"character" => Block::Para(Paragraph::with_text("AaBbCcYyZz", CharProps { style: Some(id.to_string()), ..CharProps::default() })),
|
||||
_ => {
|
||||
let mut p = Paragraph::with_text("AaBbCcYyZz", CharProps::default()).styled(id);
|
||||
p.props.space_before = Some(0.0);
|
||||
p.props.indent_left = Some(0.0);
|
||||
p.props.indent_first = Some(0.0);
|
||||
p.props.numbering = None;
|
||||
Block::Para(p)
|
||||
}
|
||||
};
|
||||
snippet_blocks(doc, vec![block], w, h, ppp, 4.0, None)
|
||||
});
|
||||
if let Some(hd) = tex {
|
||||
let sz = hd.size_vec2() / ppp;
|
||||
let ir = Rect::from_min_size(pos2(r.min.x + 2.0, r.min.y + 2.0), sz.min(r.size() - vec2(4.0, 4.0)));
|
||||
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2((ir.width() / sz.x).min(1.0), (ir.height() / sz.y).min(1.0)));
|
||||
ui.painter().with_clip_rect(r).image(hd.id(), ir, uv, egui::Color32::WHITE);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Home tab's Styles gallery.
|
||||
pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
|
|
@ -329,7 +378,9 @@ pub fn table_style_tile(ui: &mut Ui, app: &mut WordApp, style: &str) -> Response
|
|||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
let key = format!("tstyle:{style}:{ppp}");
|
||||
// Keyed by the style sheet too, so a modified table style redraws.
|
||||
let skey = app.previews.styles_key(&app.session.doc, app.session.rev());
|
||||
let key = format!("tstyle:{style}:{skey}:{ppp}");
|
||||
let doc = &app.session.doc;
|
||||
let tex = app.previews.get_or(ui.ctx(), &key, || {
|
||||
let mut tb = Table::new(5, 4, 100.0);
|
||||
|
|
|
|||
|
|
@ -250,11 +250,12 @@ fn mi_check(ui: &mut Ui, app: &mut WordApp, label: &str, checked: bool, id: &str
|
|||
fn home(app: &mut WordApp, ui: &mut Ui) {
|
||||
let st = app.session.run("format.state", &json!({})).unwrap_or_default();
|
||||
let flag = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false);
|
||||
group(ui, "Clipboard", None, app, |ui, app| {
|
||||
group(ui, "Clipboard", Some("edit.clipboardPane"), app, |ui, app| {
|
||||
menu_button(ui, app, "paste", Some("Paste"), "Paste (⌘V)", true, |ui, app| {
|
||||
mi(ui, app, "Paste", "edit.paste", json!({}));
|
||||
mi(ui, app, "Keep Text Only", "edit.pasteText", json!({}));
|
||||
mi(ui, app, "Merge Formatting", "edit.pasteMerge", json!({}));
|
||||
mi(ui, app, "Paste Special…", "edit.pasteSpecial", json!({}));
|
||||
});
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "cut", Some("Cut"), "Cut", "edit.cut", json!({}), false);
|
||||
|
|
@ -466,7 +467,11 @@ fn home(app: &mut WordApp, ui: &mut Ui) {
|
|||
});
|
||||
group(ui, "Editing", None, app, |ui, app| {
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "find", Some("Find"), "Find", "ui.dialog", json!({"name": "find"}), false).clicked();
|
||||
menu_button(ui, app, "find", Some("Find"), "Find", false, |ui, app| {
|
||||
mi(ui, app, "Find", "ui.dialog", json!({"name": "find"}));
|
||||
mi(ui, app, "Advanced Find…", "edit.advancedFind", json!({}));
|
||||
mi(ui, app, "Go To…", "ui.dialog", json!({"name": "goto"}));
|
||||
});
|
||||
small(ui, app, "replace", Some("Replace"), "Replace", "ui.dialog", json!({"name": "replace"}), false);
|
||||
menu_button(ui, app, "select", Some("Select"), "Select", false, |ui, app| {
|
||||
mi(ui, app, "Select All", "select.all", json!({}));
|
||||
|
|
@ -795,12 +800,20 @@ fn layout(app: &mut WordApp, ui: &mut Ui) {
|
|||
});
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "align", None, "Align", "arrange.align", json!({}), false);
|
||||
small(ui, app, "group", None, "Group", "arrange.group", json!({}), false);
|
||||
group_menu(ui, app, None);
|
||||
small(ui, app, "rotate", None, "Rotate", "arrange.rotate", json!({}), false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Arrange › Group: Group (Shift+click objects to select several) and Ungroup.
|
||||
fn group_menu(ui: &mut Ui, app: &mut WordApp, label: Option<&str>) {
|
||||
menu_button(ui, app, "group", label, "Group", false, |ui, app| {
|
||||
mi(ui, app, "Group", "arrange.group", json!({}));
|
||||
mi(ui, app, "Ungroup", "arrange.ungroup", json!({}));
|
||||
});
|
||||
}
|
||||
|
||||
fn references(app: &mut WordApp, ui: &mut Ui) {
|
||||
group(ui, "Table of Contents", None, app, |ui, app| {
|
||||
menu_button(ui, app, "toc", Some("Table of\nContents"), "Table of Contents", true, |ui, app| {
|
||||
|
|
@ -1053,6 +1066,11 @@ fn view(app: &mut WordApp, ui: &mut Ui) {
|
|||
group(ui, "Zoom", None, app, |ui, app| {
|
||||
big(ui, app, "zoom", "Zoom", "ui.dialog", json!({"name": "zoom"}), false);
|
||||
big(ui, app, "zoom100", "100%", "view.zoom100", json!({}), false);
|
||||
// Step the zoom up and down by 10% (issue #67), from whatever the page shows now.
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "zoomIn", Some("Zoom In"), "Zoom In", "view.zoomIn", json!({}), false);
|
||||
small(ui, app, "zoomOut", Some("Zoom Out"), "Zoom Out", "view.zoomOut", json!({}), false);
|
||||
});
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "onePage", Some("One Page"), "One Page", "view.onePage", json!({}), v.fit == "onePage");
|
||||
small(ui, app, "multiplePages", Some("Multiple Pages"), "Multiple Pages", "view.multiplePages", json!({}), v.multi_page);
|
||||
|
|
@ -1222,6 +1240,7 @@ fn picture_format(app: &mut WordApp, ui: &mut Ui) {
|
|||
stack(ui, |ui| {
|
||||
small(ui, app, "rotate", Some("Rotate"), "Rotate", "arrange.rotate", json!({}), false);
|
||||
small(ui, app, "align", Some("Align"), "Align", "arrange.align", json!({}), false);
|
||||
group_menu(ui, app, Some("Group"));
|
||||
});
|
||||
});
|
||||
group(ui, "Size", None, app, |ui, app| {
|
||||
|
|
@ -1319,18 +1338,20 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) {
|
|||
});
|
||||
});
|
||||
group(ui, "Table Styles", None, app, |ui, app| {
|
||||
let styles: Vec<(String, String)> = app
|
||||
// The document's own styles first, so a new one shows without scrolling.
|
||||
let mut styles: Vec<(bool, String, String)> = app
|
||||
.session
|
||||
.doc
|
||||
.styles
|
||||
.styles
|
||||
.iter()
|
||||
.filter(|s| s.kind == wordcraft_doc::StyleKind::Table && !s.hidden)
|
||||
.map(|s| (s.id.clone(), s.name.clone()))
|
||||
.map(|s| (s.builtin, s.id.clone(), s.name.clone()))
|
||||
.collect();
|
||||
styles.sort_by_key(|(builtin, _, _)| *builtin);
|
||||
egui::ScrollArea::horizontal().max_width(420.0).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for (id, name) in styles {
|
||||
for (_, id, name) in styles {
|
||||
if crate::previews::table_style_tile(ui, app, &id).on_hover_text(name).clicked() {
|
||||
let _ = app.run("table.style", json!({"style": id}));
|
||||
}
|
||||
|
|
@ -1338,6 +1359,32 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) {
|
|||
});
|
||||
});
|
||||
stack(ui, |ui| {
|
||||
let current = look.and_then(|_| {
|
||||
let (tp, _, _) = app.session.sel.focus.path.cell()?;
|
||||
let id = app.session.doc.table(app.session.sel.focus.story, &tp)?.props.style.clone()?;
|
||||
app.session
|
||||
.doc
|
||||
.styles
|
||||
.get(&id)
|
||||
.filter(|st| st.kind == wordcraft_doc::StyleKind::Table)
|
||||
.map(wordcraft_engine::cmd::table_style::is_builtin_table_style)
|
||||
});
|
||||
let can_modify = current.is_some();
|
||||
// Built-in table styles can't be deleted.
|
||||
let can_delete = current == Some(false);
|
||||
menu_button(ui, app, "styles", Some("Styles"), "Table Styles", false, |ui, app| {
|
||||
mi(ui, app, "New Table Style…", "ui.dialog", json!({"name": "newTableStyle"}));
|
||||
if ui.add_enabled(can_modify, egui::Button::new(tl!("Modify Table Style…")).min_size(vec2(200.0, 0.0))).clicked() {
|
||||
let _ = app.run("ui.dialog", json!({"name": "modifyTableStyle"}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.add_enabled(can_delete, egui::Button::new(tl!("Delete Table Style")).min_size(vec2(200.0, 0.0))).clicked() {
|
||||
if let Err(e) = app.run("table.deleteStyle", json!({})) {
|
||||
app.status(e);
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
split(ui, app, "shading", "Shading", "table.shading", json!({"color": app.canvas.last_shading.clone()}), false, None, |ui, app| {
|
||||
mi(ui, app, "No Color", "table.shading", json!({"color": null}));
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
|
|
@ -1374,8 +1421,8 @@ fn table_layout(app: &mut WordApp, ui: &mut Ui) {
|
|||
mi(ui, app, "Select Row", "table.selectRow", json!({}));
|
||||
mi(ui, app, "Select Table", "table.selectTable", json!({}));
|
||||
});
|
||||
let g = app.session.view.gridlines;
|
||||
small(ui, app, "gridlines", Some("View Gridlines"), "View Gridlines", "view.gridlines", json!({}), g);
|
||||
let g = app.session.view.table_gridlines;
|
||||
small(ui, app, "gridlines", Some("View Gridlines"), "View Gridlines", "table.viewGridlines", json!({}), g);
|
||||
small(ui, app, "properties", Some("Properties"), "Table Properties", "table.properties", json!({}), false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,332 @@
|
|||
//! How the document canvas scrolls (issue #122).
|
||||
//!
|
||||
//! egui's built-in wheel handling smooths every delta it thinks is coarse and drops the deltas of
|
||||
//! a touchpad's first and last events, and Wayland compositors leave kinetic scrolling to the app
|
||||
//! (GTK, Qt and browsers synthesise it after the fingers lift). So the canvas scrolls itself:
|
||||
//! - touchpad (pixel) deltas move the page 1:1, with no animation;
|
||||
//! - after a touchpad flick the page coasts and slows down ([`Momentum`]), on platforms whose
|
||||
//! system doesn't already send momentum events (not macOS, Windows or the web);
|
||||
//! - a mouse-wheel notch scrolls about three text lines and eases in over ~0.1 s, like egui.
|
||||
|
||||
use egui::{Event, InputOptions, Modifiers, MouseWheelUnit, TouchPhase, Vec2};
|
||||
|
||||
/// Screen points one mouse-wheel notch scrolls at 100% zoom: about three lines of body text,
|
||||
/// like other desktop apps (egui's default is 40).
|
||||
pub const NOTCH_PX: f32 = 56.0;
|
||||
|
||||
/// Whether to synthesise kinetic scrolling. macOS and Windows send their own momentum events
|
||||
/// (or report touchpads as line deltas), and browsers do their own; only Linux/BSD need it.
|
||||
const SYNTH_MOMENTUM: bool = !cfg!(any(target_os = "macos", target_os = "ios", target_os = "windows", target_arch = "wasm32"));
|
||||
|
||||
/// Longest frame time trusted for velocity and decay (a stall shouldn't fling the page).
|
||||
const MAX_DT: f32 = 0.1;
|
||||
/// Shortest event spacing trusted for velocity (two events in one burst aren't infinitely fast).
|
||||
const MIN_DT: f32 = 1.0 / 500.0;
|
||||
/// Velocity is averaged over roughly this many seconds of input.
|
||||
const VELOCITY_WINDOW: f32 = 0.04;
|
||||
/// With no "fingers lifted" event, coast after this long without input.
|
||||
const IDLE_RELEASE: f32 = 0.05;
|
||||
/// Fingers that rested this long before lifting don't fling.
|
||||
const STALE: f32 = 0.1;
|
||||
/// Slower flicks than this (points/s) don't coast…
|
||||
const MIN_FLING: f32 = 150.0;
|
||||
/// …and coasting stops below this speed.
|
||||
const STOP_SPEED: f32 = 20.0;
|
||||
/// Fastest coast (points/s).
|
||||
const MAX_SPEED: f32 = 8000.0;
|
||||
/// Exponential decay rate of the coast (1/s): speed falls to 1/e every ~0.33 s.
|
||||
const DECAY: f32 = 3.0;
|
||||
|
||||
/// Kinetic scrolling for touchpads: tracks the scroll velocity while the fingers move and keeps
|
||||
/// scrolling with exponentially decaying speed after they lift. Time-based, so frame-rate
|
||||
/// independent. Deltas are in screen points, positive = content moves down/right.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Momentum {
|
||||
velocity: Vec2,
|
||||
/// Seconds since the last fed delta.
|
||||
since_input: f32,
|
||||
/// Fingers are on the touchpad (deltas arrived and no lift yet).
|
||||
streaming: bool,
|
||||
coasting: bool,
|
||||
/// The device reports when the fingers lift, so don't guess it from a pause.
|
||||
reports_lift: bool,
|
||||
}
|
||||
|
||||
impl Momentum {
|
||||
/// A frame's touchpad delta, `dt` seconds after the previous frame. Non-finite input is ignored.
|
||||
pub fn feed(&mut self, delta: Vec2, dt: f32) {
|
||||
if !delta.is_finite() || !dt.is_finite() {
|
||||
return;
|
||||
}
|
||||
let dt = dt.clamp(0.0, MAX_DT);
|
||||
let fresh = !self.streaming || self.since_input + dt > STALE;
|
||||
let span = if fresh { dt } else { self.since_input + dt }.clamp(MIN_DT, MAX_DT);
|
||||
let sample = delta / span;
|
||||
self.velocity = if fresh {
|
||||
sample
|
||||
} else {
|
||||
let a = 1.0 - (-span / VELOCITY_WINDOW).exp();
|
||||
self.velocity + (sample - self.velocity) * a
|
||||
};
|
||||
let speed = self.velocity.length();
|
||||
if speed > MAX_SPEED {
|
||||
self.velocity *= MAX_SPEED / speed;
|
||||
}
|
||||
self.since_input = 0.0;
|
||||
self.streaming = true;
|
||||
self.coasting = false;
|
||||
}
|
||||
|
||||
/// The fingers lifted: coast if the flick was fast and recent enough.
|
||||
pub fn release(&mut self) {
|
||||
self.reports_lift = true;
|
||||
self.start_coast();
|
||||
}
|
||||
|
||||
fn start_coast(&mut self) {
|
||||
let fling = self.streaming && self.since_input <= STALE && self.velocity.length() >= MIN_FLING;
|
||||
self.streaming = false;
|
||||
if fling {
|
||||
self.coasting = true;
|
||||
} else {
|
||||
self.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop dead (a click, a key, a wheel notch, the end of the document).
|
||||
pub fn cancel(&mut self) {
|
||||
self.velocity = Vec2::ZERO;
|
||||
self.coasting = false;
|
||||
self.streaming = false;
|
||||
}
|
||||
|
||||
/// Advance a frame without touchpad input; returns how far to coast this frame.
|
||||
pub fn tick(&mut self, dt: f32) -> Vec2 {
|
||||
if !dt.is_finite() || dt <= 0.0 {
|
||||
return Vec2::ZERO;
|
||||
}
|
||||
let dt = dt.min(MAX_DT);
|
||||
self.since_input += dt;
|
||||
if self.streaming && !self.reports_lift && self.since_input >= IDLE_RELEASE {
|
||||
self.start_coast();
|
||||
}
|
||||
if !self.coasting {
|
||||
return Vec2::ZERO;
|
||||
}
|
||||
// Exact integral of v·e^(−kt) over the frame, so the distance doesn't depend on frame rate.
|
||||
let keep = (-DECAY * dt).exp();
|
||||
let step = self.velocity * ((1.0 - keep) / DECAY);
|
||||
self.velocity *= keep;
|
||||
if self.velocity.length() < STOP_SPEED {
|
||||
self.cancel();
|
||||
}
|
||||
step
|
||||
}
|
||||
|
||||
/// Still moving or waiting to decide whether to coast: keep repainting.
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.coasting || (self.streaming && !self.reports_lift)
|
||||
}
|
||||
}
|
||||
|
||||
/// The canvas's scrolling state: momentum plus the not-yet-scrolled part of wheel notches.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CanvasScroll {
|
||||
momentum: Momentum,
|
||||
/// Wheel distance still to scroll, eased out over a few frames.
|
||||
pending: Vec2,
|
||||
}
|
||||
|
||||
impl CanvasScroll {
|
||||
/// This frame's scroll delta in screen points (positive = content moves down/right) from the
|
||||
/// wheel/touchpad events in `input`. `hovered`: the pointer is over the canvas (only then do
|
||||
/// its wheel events scroll it). `notch`: points per wheel notch. `page`: viewport height.
|
||||
pub fn frame(&mut self, input: &egui::InputState, opts: &InputOptions, hovered: bool, notch: f32, page: f32) -> Vec2 {
|
||||
let notch = if notch.is_finite() { notch.clamp(1.0, 1000.0) } else { NOTCH_PX };
|
||||
let page = if page.is_finite() { page.clamp(0.0, 100_000.0) } else { 0.0 };
|
||||
// Browsers report lines (Firefox: three per notch) where native platforms report notches.
|
||||
let line = if cfg!(target_arch = "wasm32") { notch / 3.0 } else { notch };
|
||||
let dt = input.unstable_dt;
|
||||
let mut now = Vec2::ZERO;
|
||||
let mut touchpad = Vec2::ZERO;
|
||||
let mut lifted = false;
|
||||
let mut stop = false;
|
||||
for event in &input.events {
|
||||
match event {
|
||||
Event::MouseWheel { unit, delta, phase, modifiers } => {
|
||||
// Ctrl/⌘+wheel zooms (egui turns it into a zoom factor).
|
||||
if !hovered || modifiers.matches_any(opts.zoom_modifier) {
|
||||
continue;
|
||||
}
|
||||
let d = axis_lock(if delta.is_finite() { *delta } else { Vec2::ZERO }, *modifiers, opts);
|
||||
match unit {
|
||||
// Touchpads (and precise mice): 1:1. Browsers also report wheel notches in
|
||||
// points, so big jumps there still ease in, like egui does.
|
||||
MouseWheelUnit::Point if !cfg!(target_arch = "wasm32") || d.length() < 8.0 => touchpad += d,
|
||||
MouseWheelUnit::Point => self.pending += d,
|
||||
// Whole notches ease in; fractional ones (X11/Windows touchpads, free-spinning
|
||||
// wheels) are already smooth.
|
||||
MouseWheelUnit::Line if d.x.fract() == 0.0 && d.y.fract() == 0.0 => {
|
||||
stop = true;
|
||||
self.pending += d * line;
|
||||
}
|
||||
MouseWheelUnit::Line => now += d * line,
|
||||
MouseWheelUnit::Page => self.pending += d * page,
|
||||
}
|
||||
lifted |= matches!(phase, TouchPhase::End | TouchPhase::Cancel);
|
||||
}
|
||||
Event::Key { pressed: true, .. } | Event::PointerButton { pressed: true, .. } | Event::Text(_) => stop = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
now += touchpad;
|
||||
let fed = touchpad != Vec2::ZERO;
|
||||
if SYNTH_MOMENTUM {
|
||||
if fed {
|
||||
self.momentum.feed(touchpad, dt);
|
||||
}
|
||||
if lifted {
|
||||
self.momentum.release();
|
||||
}
|
||||
}
|
||||
// A click, key or wheel notch stops a coast (even one that started this frame).
|
||||
if stop {
|
||||
self.momentum.cancel();
|
||||
}
|
||||
if SYNTH_MOMENTUM && !fed {
|
||||
now += self.momentum.tick(dt);
|
||||
}
|
||||
// Ease wheel notches in: 90% of the way in 0.1 s (egui's curve).
|
||||
if self.pending != Vec2::ZERO {
|
||||
let t = if dt.is_finite() { egui::emath::exponential_smooth_factor(0.9, 0.1, dt.clamp(0.0, MAX_DT)) } else { 1.0 };
|
||||
let ease = |pending: f32| if pending.abs() < 1.0 { pending } else { pending * t };
|
||||
let step = Vec2::new(ease(self.pending.x), ease(self.pending.y));
|
||||
now += step;
|
||||
self.pending -= step;
|
||||
}
|
||||
now
|
||||
}
|
||||
|
||||
/// The scroll ran into the end of the document: stop coasting and drop queued notches.
|
||||
pub fn hit_edge(&mut self) {
|
||||
self.momentum.cancel();
|
||||
self.pending = Vec2::ZERO;
|
||||
}
|
||||
|
||||
/// Scrolling continues without new input: keep repainting.
|
||||
pub fn is_animating(&self) -> bool {
|
||||
self.pending != Vec2::ZERO || self.momentum.is_active()
|
||||
}
|
||||
}
|
||||
|
||||
/// Shift scrolls sideways and Alt scrolls vertically, like egui's own scroll areas.
|
||||
fn axis_lock(d: Vec2, modifiers: Modifiers, opts: &InputOptions) -> Vec2 {
|
||||
let horizontal = modifiers.matches_any(opts.horizontal_scroll_modifier);
|
||||
let vertical = modifiers.matches_any(opts.vertical_scroll_modifier);
|
||||
match (horizontal, vertical) {
|
||||
(true, false) => Vec2::new(d.x + d.y, 0.0),
|
||||
(false, true) => Vec2::new(0.0, d.x + d.y),
|
||||
_ => d,
|
||||
}
|
||||
}
|
||||
|
||||
/// Points per wheel notch at `zoom` (1.0 = 100%): more when zoomed in, less when zoomed out, but
|
||||
/// gently, so zoomed-out overviews don't crawl and close-ups don't jump.
|
||||
pub fn notch_px(zoom: f32) -> f32 {
|
||||
let z = if zoom.is_finite() && zoom > 0.0 { zoom.sqrt().clamp(0.75, 2.0) } else { 1.0 };
|
||||
NOTCH_PX * z
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FRAME: f32 = 1.0 / 60.0;
|
||||
|
||||
fn flick(m: &mut Momentum) {
|
||||
for _ in 0..6 {
|
||||
m.feed(Vec2::new(0.0, -30.0), FRAME);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flick_coasts_then_decays_to_a_stop() {
|
||||
let mut m = Momentum::default();
|
||||
flick(&mut m);
|
||||
m.release();
|
||||
let first = m.tick(FRAME);
|
||||
assert!(first.y < -10.0, "keeps going the same way after the fingers lift: {first:?}");
|
||||
let mut total = first.y;
|
||||
let mut last = first.y.abs();
|
||||
let mut frames = 0;
|
||||
while m.is_active() {
|
||||
let d = m.tick(FRAME).y;
|
||||
assert!(d.abs() <= last + 1e-3, "slows down");
|
||||
last = d.abs();
|
||||
total += d;
|
||||
frames += 1;
|
||||
assert!(frames < 600, "stops within ten seconds");
|
||||
}
|
||||
// 1800 pt/s with a 1/3 s time constant: a light coast of about 600 pt.
|
||||
assert!((-700.0..-400.0).contains(&total), "coasted {total}");
|
||||
assert_eq!(m.tick(FRAME), Vec2::ZERO);
|
||||
|
||||
// Frame-rate independent: 144 Hz coasts about as far.
|
||||
let mut fast = Momentum::default();
|
||||
flick(&mut fast);
|
||||
fast.release();
|
||||
let mut total144 = 0.0;
|
||||
while fast.is_active() {
|
||||
total144 += fast.tick(1.0 / 144.0).y;
|
||||
}
|
||||
assert!((total144 - total).abs() < 30.0, "{total144} vs {total}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_input_cancels_and_slow_or_stale_lifts_dont_fling() {
|
||||
let mut m = Momentum::default();
|
||||
flick(&mut m);
|
||||
m.release();
|
||||
assert_ne!(m.tick(FRAME), Vec2::ZERO);
|
||||
m.cancel();
|
||||
assert!(!m.is_active());
|
||||
assert_eq!(m.tick(FRAME), Vec2::ZERO);
|
||||
|
||||
// Fingers touching again mid-coast take over: no coasting until they lift again.
|
||||
flick(&mut m);
|
||||
m.release();
|
||||
m.feed(Vec2::new(0.0, -1.0), FRAME);
|
||||
assert_eq!(m.tick(FRAME), Vec2::ZERO, "the coast stopped");
|
||||
m.release();
|
||||
assert!(!m.is_active(), "a slow drag doesn't fling");
|
||||
|
||||
// Resting the fingers before lifting doesn't fling either.
|
||||
flick(&mut m);
|
||||
for _ in 0..10 {
|
||||
m.tick(FRAME);
|
||||
}
|
||||
m.release();
|
||||
assert!(!m.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_numbers_are_ignored() {
|
||||
let mut m = Momentum::default();
|
||||
m.feed(Vec2::new(f32::NAN, f32::INFINITY), FRAME);
|
||||
m.feed(Vec2::new(0.0, -30.0), f32::NAN);
|
||||
assert!(!m.is_active());
|
||||
flick(&mut m);
|
||||
m.feed(Vec2::new(0.0, f32::NEG_INFINITY), FRAME);
|
||||
m.release();
|
||||
assert_eq!(m.tick(f32::NAN), Vec2::ZERO);
|
||||
assert_eq!(m.tick(f32::INFINITY), Vec2::ZERO);
|
||||
let d = m.tick(FRAME);
|
||||
assert!(d.is_finite() && d.y < 0.0);
|
||||
// A huge delta in a tiny frame is capped.
|
||||
let mut m = Momentum::default();
|
||||
m.feed(Vec2::new(0.0, 1e30), 1e-9);
|
||||
m.release();
|
||||
assert!(m.tick(FRAME).length() <= MAX_SPEED * FRAME);
|
||||
assert!(notch_px(f32::NAN).is_finite() && notch_px(-1.0) == NOTCH_PX);
|
||||
}
|
||||
}
|
||||
|
|
@ -99,6 +99,8 @@ pub struct Tokens {
|
|||
pub ruler_tick: Color32,
|
||||
pub status_bar: Color32,
|
||||
pub selection: Color32,
|
||||
/// Find › Reading Highlight.
|
||||
pub find_highlight: Color32,
|
||||
pub caret: Color32,
|
||||
pub page_shadow: Color32,
|
||||
/// Selection-frame handles (pictures, shapes, text boxes) and their shadow.
|
||||
|
|
@ -140,6 +142,7 @@ impl Tokens {
|
|||
ruler_tick: Color32::from_rgb(0x70, 0x70, 0x70),
|
||||
status_bar: Color32::from_rgb(0xF0, 0xF0, 0xF0),
|
||||
selection: Color32::from_rgba_unmultiplied(0x3B, 0x5B, 0xDB, 0x48),
|
||||
find_highlight: Color32::from_rgba_unmultiplied(0xFF, 0xE0, 0x3D, 0x90),
|
||||
caret: Color32::BLACK,
|
||||
page_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x22),
|
||||
handle: Color32::WHITE,
|
||||
|
|
@ -179,6 +182,7 @@ impl Tokens {
|
|||
ruler_tick: Color32::from_rgb(0xA0, 0xA0, 0xA0),
|
||||
status_bar: Color32::from_rgb(0x1F, 0x1F, 0x1F),
|
||||
selection: Color32::from_rgba_unmultiplied(0x7A, 0x93, 0xF0, 0x55),
|
||||
find_highlight: Color32::from_rgba_unmultiplied(0xC8, 0xA8, 0x00, 0x70),
|
||||
caret: Color32::BLACK,
|
||||
page_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x60),
|
||||
handle: Color32::from_rgb(0xF2, 0xF2, 0xF2),
|
||||
|
|
|
|||
|
|
@ -2,42 +2,36 @@
|
|||
|
||||
Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.
|
||||
|
||||
**379 of 428 catalog features have commands (88.6%).**
|
||||
**390 of 431 catalog features have commands (90.5%).**
|
||||
|
||||
| Tab | Live | Total |
|
||||
|---|---|---|
|
||||
| File | 20 | 20 |
|
||||
| Quick Access | 4 | 4 |
|
||||
| Home | 92 | 101 |
|
||||
| Home | 98 | 101 |
|
||||
| Insert | 42 | 53 |
|
||||
| Draw | 0 | 11 |
|
||||
| Design | 10 | 10 |
|
||||
| Layout | 18 | 19 |
|
||||
| Layout | 19 | 19 |
|
||||
| References | 24 | 24 |
|
||||
| Mailings | 17 | 17 |
|
||||
| Review | 26 | 29 |
|
||||
| View | 25 | 29 |
|
||||
| Table Design | 5 | 5 |
|
||||
| Table Design | 8 | 8 |
|
||||
| Table Layout | 28 | 30 |
|
||||
| Equation | 21 | 22 |
|
||||
| Picture Format | 13 | 13 |
|
||||
| Shape Format | 3 | 6 |
|
||||
| Header & Footer | 5 | 5 |
|
||||
| Editing | 23 | 24 |
|
||||
| Editing | 24 | 24 |
|
||||
| Tools | 3 | 6 |
|
||||
|
||||
## Missing
|
||||
|
||||
### Home
|
||||
|
||||
- Clipboard › Paste Special (`edit.pasteSpecial`)
|
||||
- Clipboard › Clipboard Pane (`edit.clipboardPane`)
|
||||
- Font › Phonetic Guide (`format.phonetic`)
|
||||
- Font › Enclose Characters (`format.enclose`)
|
||||
- Paragraph › Asian Typography (`para.asianTypography`)
|
||||
- Styles › Style Inspector (`styles.inspector`)
|
||||
- Styles › Manage Styles (`styles.manage`)
|
||||
- Editing › Advanced Find (`edit.advancedFind`)
|
||||
- Voice › Dictate (`tools.dictate`)
|
||||
|
||||
### Insert
|
||||
|
|
@ -68,10 +62,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
|
|||
- Insert › Drawing Canvas (`insert.canvas`)
|
||||
- Replay › Ink Replay (`draw.replay`)
|
||||
|
||||
### Layout
|
||||
|
||||
- Arrange › Group (`arrange.group`)
|
||||
|
||||
### Review
|
||||
|
||||
- Language › Translate (`review.translate`)
|
||||
|
|
@ -100,10 +90,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
|
|||
- Text › Align Text (`shape.alignText`)
|
||||
- Text › Create Link (`shape.link`)
|
||||
|
||||
### Editing
|
||||
|
||||
- Selection › Column Selection (`select.column`)
|
||||
|
||||
### Tools
|
||||
|
||||
- Customize › Customize Ribbon (`tools.customizeRibbon`)
|
||||
|
|
|
|||
Loading…
Reference in New Issue