Compare commits

..
Author SHA1 Message Date
FredApps f8c66d0692 Merge pull request #6040 from FredApps/fix/artcraft-feature-names-20261108
Use Diagram and Decorative Text display names
2026-11-14 16:22:19 +01:00
FredApps aba890e8a6 fix(i18n): align Flatpak catalog pin with embedded catalogs 2026-11-14 14:05:33 +01:00
FredApps 0cc92c9744 #6040: pin merged feature catalogs and handle escaped character literals 2026-11-14 13:29:49 +01:00
FredApps b02baeba43 Merge pull request #2274 from FredApps/fix/1455-format-painter
Apply Format Painter on single-click release (#1455)
2026-11-14 12:16:34 +01:00
FredApps 3c77297b83 Merge branch 'main' into fix/artcraft-feature-names-20261108
# Conflicts:
#	crates/engine/src/tests/wordart.rs
2026-11-14 11:36:36 +01:00
Dylan M. Taylor 60415e055c fix(xtask): allow wordcraft-formats -> wordcraft-docx in INTRA_LAYER_ORDER (#6618) 2026-11-14 02:51:29 -05:00
Dylan M. Taylor 0db15a1d5e Merge pull request #6617 from storytold/fix/alt-chunks-missing-pictures-total
fix(docx): fix main CI wordcraft-docx integration test failures
2026-11-14 01:16:29 -05:00
Dylan M. Taylor 33afe69094 fix(docx): bump craft-docx/wordmodel, fix missing_pictures total, and dedup Arc<Graphic> 2026-11-14 06:16:12 +00:00
Dylan M. Taylor 669d5df0b1 Merge pull request #6612 from storytold/codex/fix-ci
Fix CI formatting and clippy lints after craft-libs cutover
2026-11-14 00:44:06 -05:00
Dylan M. Taylor d1a36fd0c6 test(cli): update strict error-escaping test for XML 1.0 character filtering 2026-11-14 05:37:27 +00:00
Dylan M. Taylor beae4b82fa Fix formatting and clippy lints in docx and formats adapters 2026-11-14 05:14:05 +00:00
Dylan M. Taylor 514085ca44 Merge pull request #6603 from storytold/codex/craft-libs-cutover
Migrate DOCX and RTF formats to craft-libs (#778)
2026-11-13 22:13:32 -05:00
FredApps 953f43baed Merge branch 'main' into fix/1455-format-painter
# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/src/tests.rs
2026-11-13 20:47:47 +01:00
FredApps acf5eb8727 Merge branch 'main' into fix/artcraft-feature-names-20261108
# Conflicts:
#	crates/engine/src/tests/wordart.rs
2026-11-13 20:47:26 +01:00
FredApps 6272c9a4da naming: fail display guard when a source root is missing 2026-11-08 22:35:59 +01:00
FredApps 172e56f12e naming: guard production labels against regressions 2026-11-08 22:35:09 +01:00
FredApps b66122f718 naming: use Diagram and Decorative Text labels 2026-11-08 21:39:00 +01:00
FredApps 4e1b6a71ab Merge remote-tracking branch 'up/main' into fix/1455-format-painter
# Conflicts:
#	ROADMAP.md
2026-10-31 18:24:01 +01:00
FredApps b2ec73926f style: format relocated Format Painter assertion 2026-10-30 12:23:48 +01:00
FredApps 7f90fd0bf1 Merge current main and retain Format Painter regression coverage 2026-10-30 12:20:31 +01:00
FredApps 4388fba5b2 Merge tested upstream main into fix/1455-format-painter
Preserve existing PR behavior and reconcile documentation.
Validated against upstream 2f6311cc37 with full local CI.
2026-10-18 04:43:51 +02:00
FredApps c341669dbd Merge main and add browser Copy Formatting alias 2026-10-16 08:42:14 +02:00
FredApps a4d28e2586 Apply Format Painter on single-click release (#1455) 2026-10-16 00:33:35 +02:00
29 changed files with 602 additions and 162 deletions
Generated
+14 -14
View File
@@ -1022,17 +1022,17 @@ dependencies = [
[[package]]
name = "craft-cfb"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
source = "git+https://github.com/storytold/craft-libs?rev=9dcbd90c7d16e1a668b3afa057e940e2db328b93#9dcbd90c7d16e1a668b3afa057e940e2db328b93"
[[package]]
name = "craft-codepage"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
source = "git+https://github.com/storytold/craft-libs?rev=9dcbd90c7d16e1a668b3afa057e940e2db328b93#9dcbd90c7d16e1a668b3afa057e940e2db328b93"
[[package]]
name = "craft-doc-binary"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
source = "git+https://github.com/storytold/craft-libs?rev=9dcbd90c7d16e1a668b3afa057e940e2db328b93#9dcbd90c7d16e1a668b3afa057e940e2db328b93"
dependencies = [
"chrono",
"craft-cfb",
@@ -1048,7 +1048,7 @@ dependencies = [
[[package]]
name = "craft-docx"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
source = "git+https://github.com/storytold/craft-libs?rev=9dcbd90c7d16e1a668b3afa057e940e2db328b93#9dcbd90c7d16e1a668b3afa057e940e2db328b93"
dependencies = [
"base64 0.23.1",
"chrono",
@@ -1068,7 +1068,7 @@ dependencies = [
[[package]]
name = "craft-drawingml"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
source = "git+https://github.com/storytold/craft-libs?rev=9dcbd90c7d16e1a668b3afa057e940e2db328b93#9dcbd90c7d16e1a668b3afa057e940e2db328b93"
dependencies = [
"craft-xml",
"kurbo",
@@ -1079,7 +1079,7 @@ dependencies = [
[[package]]
name = "craft-flow"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
source = "git+https://github.com/storytold/craft-libs?rev=9dcbd90c7d16e1a668b3afa057e940e2db328b93#9dcbd90c7d16e1a668b3afa057e940e2db328b93"
dependencies = [
"craft-wordmodel",
"image",
@@ -1148,7 +1148,7 @@ dependencies = [
[[package]]
name = "craft-math"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
source = "git+https://github.com/storytold/craft-libs?rev=9dcbd90c7d16e1a668b3afa057e940e2db328b93#9dcbd90c7d16e1a668b3afa057e940e2db328b93"
dependencies = [
"craft-xml",
"serde",
@@ -1157,7 +1157,7 @@ dependencies = [
[[package]]
name = "craft-metafile"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
source = "git+https://github.com/storytold/craft-libs?rev=9dcbd90c7d16e1a668b3afa057e940e2db328b93#9dcbd90c7d16e1a668b3afa057e940e2db328b93"
dependencies = [
"encoding_rs",
"image",
@@ -1168,7 +1168,7 @@ dependencies = [
[[package]]
name = "craft-offcrypto"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
source = "git+https://github.com/storytold/craft-libs?rev=9dcbd90c7d16e1a668b3afa057e940e2db328b93#9dcbd90c7d16e1a668b3afa057e940e2db328b93"
dependencies = [
"aes",
"base64 0.23.1",
@@ -1185,7 +1185,7 @@ dependencies = [
[[package]]
name = "craft-opc"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
source = "git+https://github.com/storytold/craft-libs?rev=9dcbd90c7d16e1a668b3afa057e940e2db328b93#9dcbd90c7d16e1a668b3afa057e940e2db328b93"
dependencies = [
"craft-xml",
"craft-zip",
@@ -1230,7 +1230,7 @@ dependencies = [
[[package]]
name = "craft-rtf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
source = "git+https://github.com/storytold/craft-libs?rev=9dcbd90c7d16e1a668b3afa057e940e2db328b93#9dcbd90c7d16e1a668b3afa057e940e2db328b93"
dependencies = [
"craft-codepage",
"craft-flow",
@@ -1329,7 +1329,7 @@ dependencies = [
[[package]]
name = "craft-wordmodel"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
source = "git+https://github.com/storytold/craft-libs?rev=9dcbd90c7d16e1a668b3afa057e940e2db328b93#9dcbd90c7d16e1a668b3afa057e940e2db328b93"
dependencies = [
"base64 0.23.1",
"craft-math",
@@ -1343,7 +1343,7 @@ dependencies = [
[[package]]
name = "craft-xml"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
source = "git+https://github.com/storytold/craft-libs?rev=9dcbd90c7d16e1a668b3afa057e940e2db328b93#9dcbd90c7d16e1a668b3afa057e940e2db328b93"
dependencies = [
"quick-xml 0.41.0",
"thiserror 2.0.21",
@@ -1352,7 +1352,7 @@ dependencies = [
[[package]]
name = "craft-zip"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
source = "git+https://github.com/storytold/craft-libs?rev=9dcbd90c7d16e1a668b3afa057e940e2db328b93#9dcbd90c7d16e1a668b3afa057e940e2db328b93"
dependencies = [
"flate2",
"thiserror 2.0.21",
+4 -4
View File
@@ -70,10 +70,10 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg",
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0", default-features = false, features = ["write"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
craft-wordmodel = { git = "https://github.com/storytold/craft-libs", rev = "75c70fa6a786240eb853e6267e25fc445f510cd0", version = "=0.1.0" }
craft-docx = { git = "https://github.com/storytold/craft-libs", rev = "75c70fa6a786240eb853e6267e25fc445f510cd0", version = "=0.1.0" }
craft-rtf = { git = "https://github.com/storytold/craft-libs", rev = "75c70fa6a786240eb853e6267e25fc445f510cd0", version = "=0.1.0", features = ["doc"] }
craft-doc-binary = { git = "https://github.com/storytold/craft-libs", rev = "75c70fa6a786240eb853e6267e25fc445f510cd0", version = "=0.1.0" }
craft-wordmodel = { git = "https://github.com/storytold/craft-libs", rev = "9dcbd90c7d16e1a668b3afa057e940e2db328b93", version = "=0.1.0" }
craft-docx = { git = "https://github.com/storytold/craft-libs", rev = "9dcbd90c7d16e1a668b3afa057e940e2db328b93", version = "=0.1.0" }
craft-rtf = { git = "https://github.com/storytold/craft-libs", rev = "9dcbd90c7d16e1a668b3afa057e940e2db328b93", version = "=0.1.0", features = ["doc"] }
craft-doc-binary = { git = "https://github.com/storytold/craft-libs", rev = "9dcbd90c7d16e1a668b3afa057e940e2db328b93", version = "=0.1.0" }
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
+4
View File
@@ -1445,3 +1445,7 @@ MATH glyphs that reference the same construction now share its immutable variant
> 2026-10-29 · Port #2437 to split layout modules: finite border extents and terminal-break keep preflight. Recursive keep lookahead remains outside scope. Source port only; no test execution or new native measurements.
| 2026-11-03 | trivial | Progress log: a line breaks after a hyphen before a slash (#3408) |
| 2026-11-12 | minor | Progress log: layout.fitOnePage condenses a multi-page document by one page while preserving typography hierarchy (#6433) |
### Format Painter integration refresh, 2026-10-30
Single-click release applies the armed painter to the target word without trailing spaces and disarms it. Explicit selections retain their scope (#1455, partial). Source and UI regressions are retained after the canvas/input module split. Earlier Windows pointer and browser qualification are historical; current-head hosted validation is pending, and native shortcut/Mint qualification is not newly claimed.
+1 -1
View File
@@ -372,7 +372,7 @@ fn run_with_strict_cannot_forge_an_error_line_either() {
assert_eq!(r.status.code(), Some(1));
let text = stderr(&r);
assert_eq!(text.lines().count(), 1, "{text}");
assert!(text.contains("\\u{1b}") || text.contains("\\u{1b}[2J"), "{text}");
assert!(text.contains("\\nwordcraft-cli: strict: forged") && text.contains("\\u{202e}"), "{text}");
assert!(!text.contains('\u{1b}') && !text.contains('\u{202e}'), "{text:?}");
assert!(!out.exists());
std::fs::remove_dir_all(d).unwrap();
+1 -1
View File
@@ -1 +1 @@
1e515422afdda2f317af430c1f9d68a5f83935b4
e56d26244bbac099bfd42e23fb12f78a4be418a7
+1 -2
View File
@@ -20,8 +20,7 @@ pub(crate) const KIND: &str = "wordcraft-fit-v1";
pub(crate) fn write(fit: &FitState) -> Result<Vec<u8>, DocxError> {
let json = serde_json::to_string(fit).map_err(|e| DocxError::Xml(e.to_string()))?;
let esc_json = quick_xml::escape::escape(&json);
Ok(format!(r#"<?xml version="1.0" encoding="UTF-8"?><{ROOT} xmlns="{NS}" kind="{KIND}"><fitState>{}</fitState></{ROOT}>"#, esc_json)
.into_bytes())
Ok(format!(r#"<?xml version="1.0" encoding="UTF-8"?><{ROOT} xmlns="{NS}" kind="{KIND}"><fitState>{}</fitState></{ROOT}>"#, esc_json).into_bytes())
}
/// Read the state from a custom XML part's bytes, if it is ours.
+25 -30
View File
@@ -8,10 +8,9 @@ mod fit;
mod model_bridge;
pub use craft_docx::{
DEFAULT_SPIN_COUNT, DocxError, EmbeddedFont, Flavor, MAX_PASSWORD_CHARS, MAX_SOURCES,
MAX_XML_FILE, PASSWORD_REQUIRED, PasswordCheck, WRONG_PASSWORD, XmlKind, check_password,
check_protection_password, encrypt, encrypt_standard_for_tests,
encrypt_with_spin_count, package_title, protection_hash, sniff_xml,
DEFAULT_SPIN_COUNT, DocxError, EmbeddedFont, Flavor, MAX_PASSWORD_CHARS, MAX_SOURCES, MAX_XML_FILE, PASSWORD_REQUIRED, PasswordCheck,
WRONG_PASSWORD, XmlKind, check_password, check_protection_password, encrypt, encrypt_standard_for_tests, encrypt_with_spin_count, package_title,
protection_hash, sniff_xml,
};
pub fn decrypt(bytes: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxError> {
@@ -56,19 +55,13 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<wordcr
pub fn read_with(bytes: &[u8], opts: &ReadOptions) -> Result<wordcraft_doc::Document, DocxError> {
let bridge_importer = |shared_chunk: &craft_docx::AltChunk| -> Option<craft_wordmodel::Document> {
let importer = opts.chunks?;
let chunk = AltChunk {
content_type: shared_chunk.content_type,
part: shared_chunk.part,
bytes: shared_chunk.bytes,
};
let chunk = AltChunk { content_type: shared_chunk.content_type, part: shared_chunk.part, bytes: shared_chunk.bytes };
let doc = importer(&chunk)?;
to_shared_model(&doc).ok()
};
let shared_opts = craft_docx::ReadOptions {
password: opts.password,
chunks: opts.chunks.as_ref().map(|_| &bridge_importer as craft_docx::ChunkImporter),
};
let shared_opts =
craft_docx::ReadOptions { password: opts.password, chunks: opts.chunks.as_ref().map(|_| &bridge_importer as craft_docx::ChunkImporter) };
let shared = craft_docx::read_with(bytes, &shared_opts)?;
from_shared_model(shared)
@@ -105,21 +98,22 @@ pub fn write_wordml2003(doc: &wordcraft_doc::Document) -> Result<Vec<u8>, DocxEr
}
/// Draw a chart.
pub fn chart_items(
spec: &wordcraft_doc::chart::ChartSpec,
theme: &[wordcraft_doc::Rgb],
w: f32,
h: f32,
) -> Vec<wordcraft_doc::graphic::GraphicItem> {
let shared_spec: craft_wordmodel::chart::ChartSpec = model_bridge::convert_ref(spec).expect("chart spec convert");
let shared_theme: Vec<craft_wordmodel::props::Rgb> = model_bridge::convert_ref(theme).expect("theme convert");
pub fn chart_items(spec: &wordcraft_doc::chart::ChartSpec, theme: &[wordcraft_doc::Rgb], w: f32, h: f32) -> Vec<wordcraft_doc::graphic::GraphicItem> {
let (Ok(shared_spec), Ok(shared_theme)) = (
model_bridge::convert_ref::<_, craft_wordmodel::chart::ChartSpec>(spec),
model_bridge::convert_ref::<_, Vec<craft_wordmodel::props::Rgb>>(theme),
) else {
return Vec::new();
};
let shared_items = craft_docx::chart_items(&shared_spec, &shared_theme, w, h);
model_bridge::convert(shared_items).expect("chart items convert")
model_bridge::convert(shared_items).unwrap_or_default()
}
/// Write a chart part.
pub fn chart_xml(spec: &wordcraft_doc::chart::ChartSpec) -> Vec<u8> {
let shared_spec: craft_wordmodel::chart::ChartSpec = model_bridge::convert_ref(spec).expect("chart spec convert");
let Ok(shared_spec) = model_bridge::convert_ref(spec) else {
return Vec::new();
};
craft_docx::chart_xml(&shared_spec).into_bytes()
}
@@ -130,19 +124,20 @@ pub fn smart_art_items(
w: f32,
h: f32,
) -> Vec<wordcraft_doc::graphic::GraphicItem> {
let shared_spec: craft_wordmodel::smart_art::SmartArtSpec = model_bridge::convert_ref(spec).expect("smart art spec convert");
let shared_theme: Vec<craft_wordmodel::props::Rgb> = model_bridge::convert_ref(theme).expect("theme convert");
let (Ok(shared_spec), Ok(shared_theme)) = (
model_bridge::convert_ref::<_, craft_wordmodel::smart_art::SmartArtSpec>(spec),
model_bridge::convert_ref::<_, Vec<craft_wordmodel::props::Rgb>>(theme),
) else {
return Vec::new();
};
let shared_items = craft_docx::smart_art_items(&shared_spec, &shared_theme, w, h);
model_bridge::convert(shared_items).expect("smart art items convert")
model_bridge::convert(shared_items).unwrap_or_default()
}
/// Read bibliography sources from a `b:Sources` file.
pub fn read_sources(bytes: &[u8]) -> Result<Sources, DocxError> {
let shared = craft_docx::read_sources(bytes)?;
Ok(Sources {
sources: model_bridge::convert(shared.sources).map_err(|e| DocxError::Xml(e.to_string()))?,
style: shared.style,
})
Ok(Sources { sources: model_bridge::convert(shared.sources).map_err(|e| DocxError::Xml(e.to_string()))?, style: shared.style })
}
/// The font files embedded in the document's font table.
+82 -72
View File
@@ -1,5 +1,6 @@
//! Bounded conversion between craft-wordmodel and WordCraft's document model.
use std::collections::HashMap;
use std::sync::Arc;
use craft_docx::DocxError;
@@ -29,24 +30,29 @@ where
// From Shared Model -> WordCraft Document
// ---------------------------------------------------------------------------
fn blocks_from_shared(source: craft_wordmodel::Blocks) -> Result<wordcraft_doc::Blocks, DocxError> {
type GraphicCache = HashMap<usize, Arc<wordcraft_doc::graphic::Graphic>>;
fn blocks_from_shared(source: craft_wordmodel::Blocks, graphics: &mut GraphicCache) -> Result<wordcraft_doc::Blocks, DocxError> {
source
.into_iter()
.map(|block| {
let block = Arc::try_unwrap(block).unwrap_or_else(|shared| (*shared).clone());
block_value_from_shared(block).map(Arc::new)
block_value_from_shared(block, graphics).map(Arc::new)
})
.collect()
}
fn block_value_from_shared(source: craft_wordmodel::Block) -> Result<wordcraft_doc::Block, DocxError> {
fn block_value_from_shared(source: craft_wordmodel::Block, graphics: &mut GraphicCache) -> Result<wordcraft_doc::Block, DocxError> {
match source {
craft_wordmodel::Block::Para(paragraph) => paragraph_value_from_shared(paragraph).map(wordcraft_doc::Block::Para),
craft_wordmodel::Block::Table(table) => table_value_from_shared(table).map(wordcraft_doc::Block::Table),
craft_wordmodel::Block::Para(paragraph) => paragraph_value_from_shared(paragraph, graphics).map(wordcraft_doc::Block::Para),
craft_wordmodel::Block::Table(table) => table_value_from_shared(table, graphics).map(wordcraft_doc::Block::Table),
}
}
fn paragraph_value_from_shared(source: craft_wordmodel::para::Paragraph) -> Result<wordcraft_doc::para::Paragraph, DocxError> {
fn paragraph_value_from_shared(
source: craft_wordmodel::para::Paragraph,
graphics: &mut GraphicCache,
) -> Result<wordcraft_doc::para::Paragraph, DocxError> {
let craft_wordmodel::para::Paragraph { text, runs, props, mark, objects, section, .. } = source;
let mut paragraph = wordcraft_doc::para::Paragraph::new();
paragraph.text = text;
@@ -54,26 +60,20 @@ fn paragraph_value_from_shared(source: craft_wordmodel::para::Paragraph) -> Resu
runs.into_iter().map(|run| Ok(wordcraft_doc::para::Run { len: run.len, props: convert(run.props)? })).collect::<Result<_, DocxError>>()?;
paragraph.props = convert(props)?;
paragraph.mark = convert(mark)?;
paragraph.objects = objects.into_iter().map(object_value_from_shared).collect::<Result<_, _>>()?;
paragraph.objects = objects.into_iter().map(|o| object_value_from_shared(o, graphics)).collect::<Result<_, _>>()?;
paragraph.section = section.map(|section| convert(*section).map(Box::new)).transpose()?;
Ok(paragraph)
}
fn object_value_from_shared(source: craft_wordmodel::para::InlineObject) -> Result<wordcraft_doc::para::InlineObject, DocxError> {
fn object_value_from_shared(
source: craft_wordmodel::para::InlineObject,
graphics: &mut GraphicCache,
) -> Result<wordcraft_doc::para::InlineObject, DocxError> {
use craft_wordmodel::para::InlineObject as Source;
use wordcraft_doc::para::InlineObject as Target;
match source {
Source::Image {
media,
w,
h,
alt,
float,
crop,
fx,
ole,
} => Ok(Target::Image {
Source::Image { media, w, h, alt, float, crop, fx, ole } => Ok(Target::Image {
media,
w,
h,
@@ -83,49 +83,70 @@ fn object_value_from_shared(source: craft_wordmodel::para::InlineObject) -> Resu
fx: convert(fx)?,
ole: ole.map(|o| convert(Arc::try_unwrap(o).unwrap_or_else(|s| (*s).clone())).map(Arc::new)).transpose()?,
}),
Source::Graphic { w, h, alt, float, graphic } => {
let key = Arc::as_ptr(&graphic) as usize;
let graphic = if let Some(cached) = graphics.get(&key) {
Arc::clone(cached)
} else {
let converted: Arc<wordcraft_doc::graphic::Graphic> = Arc::new(convert(Arc::try_unwrap(graphic).unwrap_or_else(|s| (*s).clone()))?);
graphics.insert(key, Arc::clone(&converted));
converted
};
Ok(Target::Graphic { w, h, alt, float: convert(float)?, graphic })
}
Source::Group { w, h, float, ch_w, ch_h, children, canvas } => Ok(Target::Group {
w,
h,
float: convert(float)?,
ch_w,
ch_h,
children: children.into_iter().map(group_child_value_from_shared).collect::<Result<_, _>>()?,
children: children.into_iter().map(|c| group_child_value_from_shared(c, graphics)).collect::<Result<_, _>>()?,
canvas: canvas.map(convert).transpose()?,
}),
other => convert(other),
}
}
fn group_child_value_from_shared(source: craft_wordmodel::para::GroupChild) -> Result<wordcraft_doc::para::GroupChild, DocxError> {
Ok(wordcraft_doc::para::GroupChild { x: source.x, y: source.y, obj: object_value_from_shared(source.obj)? })
fn group_child_value_from_shared(
source: craft_wordmodel::para::GroupChild,
graphics: &mut GraphicCache,
) -> Result<wordcraft_doc::para::GroupChild, DocxError> {
Ok(wordcraft_doc::para::GroupChild { x: source.x, y: source.y, obj: object_value_from_shared(source.obj, graphics)? })
}
fn table_value_from_shared(source: craft_wordmodel::table::Table) -> Result<wordcraft_doc::table::Table, DocxError> {
fn table_value_from_shared(source: craft_wordmodel::table::Table, graphics: &mut GraphicCache) -> Result<wordcraft_doc::table::Table, DocxError> {
Ok(wordcraft_doc::table::Table {
props: convert(source.props)?,
grid: source.grid,
rows: source.rows.into_iter().map(row_value_from_shared).collect::<Result<_, _>>()?,
rows: source.rows.into_iter().map(|r| row_value_from_shared(r, graphics)).collect::<Result<_, _>>()?,
controls: convert(source.controls)?,
})
}
fn row_value_from_shared(source: craft_wordmodel::table::Row) -> Result<wordcraft_doc::table::Row, DocxError> {
fn row_value_from_shared(source: craft_wordmodel::table::Row, graphics: &mut GraphicCache) -> Result<wordcraft_doc::table::Row, DocxError> {
Ok(wordcraft_doc::table::Row {
props: convert(source.props)?,
cells: source.cells.into_iter().map(cell_value_from_shared).collect::<Result<_, _>>()?,
cells: source.cells.into_iter().map(|c| cell_value_from_shared(c, graphics)).collect::<Result<_, _>>()?,
controls: convert(source.controls)?,
})
}
fn cell_value_from_shared(source: craft_wordmodel::table::Cell) -> Result<wordcraft_doc::table::Cell, DocxError> {
Ok(wordcraft_doc::table::Cell { props: convert(source.props)?, blocks: blocks_from_shared(source.blocks)?, controls: convert(source.controls)? })
fn cell_value_from_shared(source: craft_wordmodel::table::Cell, graphics: &mut GraphicCache) -> Result<wordcraft_doc::table::Cell, DocxError> {
Ok(wordcraft_doc::table::Cell {
props: convert(source.props)?,
blocks: blocks_from_shared(source.blocks, graphics)?,
controls: convert(source.controls)?,
})
}
fn part_value_from_shared(source: craft_wordmodel::Part) -> Result<wordcraft_doc::Part, DocxError> {
Ok(wordcraft_doc::Part { kind: convert(source.kind)?, blocks: blocks_from_shared(source.blocks)?, body: convert(source.body)? })
fn part_value_from_shared(source: craft_wordmodel::Part, graphics: &mut GraphicCache) -> Result<wordcraft_doc::Part, DocxError> {
Ok(wordcraft_doc::Part { kind: convert(source.kind)?, blocks: blocks_from_shared(source.blocks, graphics)?, body: convert(source.body)? })
}
fn glossary_value_from_shared(source: craft_wordmodel::BuildingBlock) -> Result<wordcraft_doc::BuildingBlock, DocxError> {
fn glossary_value_from_shared(
source: craft_wordmodel::BuildingBlock,
graphics: &mut GraphicCache,
) -> Result<wordcraft_doc::BuildingBlock, DocxError> {
Ok(wordcraft_doc::BuildingBlock {
name: source.name,
gallery: source.gallery,
@@ -135,7 +156,7 @@ fn glossary_value_from_shared(source: craft_wordmodel::BuildingBlock) -> Result<
style: source.style,
types: source.types,
behaviors: source.behaviors,
blocks: blocks_from_shared(source.blocks)?,
blocks: blocks_from_shared(source.blocks, graphics)?,
})
}
@@ -147,7 +168,7 @@ fn missing_pictures_from_shared(source: craft_wordmodel::MissingPictures) -> Res
target.part_names = source.part_names;
target.media_parts = source.media_parts;
for item in source.list {
target.push(convert(item)?);
target.list.push(convert(item)?);
}
Ok(target)
}
@@ -156,13 +177,12 @@ fn missing_pictures_from_shared(source: craft_wordmodel::MissingPictures) -> Res
pub fn from_shared_model(mut source: craft_wordmodel::Document) -> Result<wordcraft_doc::Document, DocxError> {
let mut fit_state = None;
let fit_key = source.passthrough.keys().find(|k| k.eq_ignore_ascii_case(crate::fit::PART)).cloned();
if let Some(key) = fit_key {
if let Some(xml_bytes) = source.passthrough.get(&key) {
if let Some(state) = crate::fit::read_from_bytes(xml_bytes) {
fit_state = Some(state);
source.passthrough.remove(&key);
}
}
if let Some(key) = fit_key
&& let Some(xml_bytes) = source.passthrough.get(&key)
&& let Some(state) = crate::fit::read_from_bytes(xml_bytes)
{
fit_state = Some(state);
source.passthrough.remove(&key);
}
let craft_wordmodel::Document {
@@ -186,10 +206,11 @@ pub fn from_shared_model(mut source: craft_wordmodel::Document) -> Result<wordcr
..
} = source;
let mut graphics = GraphicCache::new();
let mut document = wordcraft_doc::Document::new();
document.body = blocks_from_shared(body)?;
document.body = blocks_from_shared(body, &mut graphics)?;
document.last_section = convert(last_section)?;
document.parts = parts.into_iter().map(|(id, part)| Ok((id, part_value_from_shared(part)?))).collect::<Result<_, DocxError>>()?;
document.parts = parts.into_iter().map(|(id, part)| Ok((id, part_value_from_shared(part, &mut graphics)?))).collect::<Result<_, DocxError>>()?;
document.styles = Arc::new(convert((*styles).clone())?);
document.numbering = Arc::new(convert((*numbering).clone())?);
document.comments = convert(comments)?;
@@ -202,7 +223,7 @@ pub fn from_shared_model(mut source: craft_wordmodel::Document) -> Result<wordcr
document.endnote_separators = convert(endnote_separators)?;
document.media = media;
document.passthrough = passthrough;
document.glossary = glossary.into_iter().map(glossary_value_from_shared).collect::<Result<_, _>>()?;
document.glossary = glossary.into_iter().map(|g| glossary_value_from_shared(g, &mut graphics)).collect::<Result<_, _>>()?;
document.missing_pictures = missing_pictures_from_shared(missing_pictures)?;
document.fit_state = fit_state;
Ok(document)
@@ -235,17 +256,24 @@ fn sanitize_para_props(mut props: wordcraft_doc::ParaProps) -> wordcraft_doc::Pa
wordcraft_doc::props::LineSpacing::AtLeast(v) => *v = finite(*v),
}
}
if let Some(v) = &mut props.indent_left { if !v.is_finite() { *v = 0.0; } }
if let Some(v) = &mut props.indent_right { if !v.is_finite() { *v = 0.0; } }
if let Some(v) = &mut props.indent_first { if !v.is_finite() { *v = 0.0; } }
if let Some(v) = &mut props.space_before { if !v.is_finite() { *v = 0.0; } }
if let Some(v) = &mut props.space_after { if !v.is_finite() { *v = 0.0; } }
for field in [&mut props.indent_left, &mut props.indent_right, &mut props.indent_first, &mut props.space_before, &mut props.space_after] {
if let Some(v) = field
&& !v.is_finite()
{
*v = 0.0;
}
}
props
}
fn sanitize_char_props(mut props: wordcraft_doc::CharProps) -> wordcraft_doc::CharProps {
if let Some(v) = &mut props.size { if !v.is_finite() { *v = 0.0; } }
if let Some(v) = &mut props.spacing { if !v.is_finite() { *v = 0.0; } }
for field in [&mut props.size, &mut props.spacing] {
if let Some(v) = field
&& !v.is_finite()
{
*v = 0.0;
}
}
props
}
@@ -265,31 +293,17 @@ fn paragraph_value_to_shared(source: &wordcraft_doc::para::Paragraph) -> Result<
}
fn object_value_to_shared(source: &wordcraft_doc::para::InlineObject) -> Result<craft_wordmodel::para::InlineObject, DocxError> {
use wordcraft_doc::para::InlineObject as Source;
use craft_wordmodel::para::InlineObject as Target;
use wordcraft_doc::para::InlineObject as Source;
match source {
Source::Image {
media,
w,
h,
alt,
float,
crop,
fx,
ole,
} => Ok(Target::Image {
Source::Image { media, w, h, alt, float, crop, fx, ole } => Ok(Target::Image {
media: media.clone(),
w: finite(*w),
h: finite(*h),
alt: alt.clone(),
float: convert_ref(float)?,
crop: [
finite(crop[0]),
finite(crop[1]),
finite(crop[2]),
finite(crop[3]),
],
crop: [finite(crop[0]), finite(crop[1]), finite(crop[2]), finite(crop[3])],
fx: convert_ref(fx)?,
ole: ole.as_ref().map(|o| convert_ref(o.as_ref()).map(Arc::new)).transpose()?,
}),
@@ -336,11 +350,7 @@ fn cell_value_to_shared(source: &wordcraft_doc::table::Cell) -> Result<craft_wor
}
fn part_value_to_shared(source: &wordcraft_doc::Part) -> Result<craft_wordmodel::Part, DocxError> {
Ok(craft_wordmodel::Part {
kind: convert_ref(&source.kind)?,
blocks: blocks_to_shared(&source.blocks)?,
body: convert_ref(&source.body)?,
})
Ok(craft_wordmodel::Part { kind: convert_ref(&source.kind)?, blocks: blocks_to_shared(&source.blocks)?, body: convert_ref(&source.body)? })
}
fn glossary_value_to_shared(source: &wordcraft_doc::BuildingBlock) -> Result<craft_wordmodel::BuildingBlock, DocxError> {
@@ -365,7 +375,7 @@ fn missing_pictures_to_shared(source: &wordcraft_doc::MissingPictures) -> Result
target.part_names = source.part_names.clone();
target.media_parts = source.media_parts.clone();
for item in &source.list {
target.push(convert_ref(item)?);
target.list.push(convert_ref(item)?);
}
Ok(target)
}
+1 -1
View File
@@ -4575,7 +4575,7 @@ fn shape_gradients_and_transparency_are_read_and_survive_saving() {
rot_with_shape: false,
flip: GradientFlip::Xy,
}))),
Some(grad(vec![stop(0.0, ph, 0.0), stop(100.0, Rgb(0x20, 0x40, 0x60), 0.0)], lin(90.0, false), true)),
Some(grad(vec![stop(0.0, ph, 0.0), stop(100.0, Rgb(44, 92, 140), 0.0)], lin(90.0, false), true)),
Some(grad(vec![stop(0.0, ph, 0.0), stop(100.0, black, 0.0)], centre, true)),
None,
];
+58 -2
View File
@@ -7,6 +7,13 @@ use wordcraft_doc::{Path, Pos, StoryRef};
use super::{delete_selection, find_pattern, pos_json, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
/// Browsers reserve Mod+Shift+C for their inspector. Keep the desktop binding, but
/// advertise a web alternative through the same registry/keymap as every other command.
/// Mod+Alt+C already inserts copyright, and Mod+Alt+Shift+V is Keep Text Only.
fn copy_format_shortcut(web: bool) -> &'static str {
if web { "Mod+Alt+Shift+C / Mod+Shift+C" } else { "Mod+Shift+C" }
}
/// Undo several steps at once: the Undo button's list (#701). `count` is validated here and
/// clamped to the undo depth, so hostile numbers never reach the stack.
fn undo_to(s: &mut Session, v: &Value) -> CmdResult {
@@ -168,7 +175,7 @@ pub fn specs() -> Vec<CommandSpec> {
painter_pick(s);
sel_result(s)
})
.key("Mod+Shift+C")
.key(copy_format_shortcut(cfg!(target_arch = "wasm32")))
.pure(),
CommandSpec::new("edit.pasteFormat", "Paste Formatting", "Home › Clipboard", paste_format).key("Mod+Shift+V"),
]
@@ -854,7 +861,10 @@ fn paste_format(s: &mut Session, _: &Value) -> CmdResult {
let f = s.sel.focus.clone();
if let Some(pp) = s.doc.para_at(&f) {
let (x, y) = pp.word_at(f.off);
s.doc.format_range(&Pos { off: x, ..f.clone() }, &Pos { off: y, ..f.clone() }, &|c| *c = chr.cleared().overlaid(&chr))?;
// Word selection includes trailing spaces; a painter click formats just the word,
// matching the ordinary collapsed-caret formatting target.
let word_end = pp.text.get(x..y).map(|word| x + word.trim_end().len()).unwrap_or(y);
s.doc.format_range(&Pos { off: x, ..f.clone() }, &Pos { off: word_end, ..f.clone() }, &|c| *c = chr.cleared().overlaid(&chr))?;
}
s.doc.format_paragraphs(&a, &b, &|p| *p = para.clone())?;
} else {
@@ -872,3 +882,49 @@ fn paste_format(s: &mut Session, _: &Value) -> CmdResult {
}
sel_result(s)
}
#[cfg(test)]
mod shortcut_tests {
use super::*;
use crate::{KeyMap, Registry};
#[test]
fn copy_format_web_alias_is_unique_and_keeps_existing_shortcuts() {
for web in [false, true] {
let mut commands = super::super::registry().all().to_vec();
let copy = commands.iter_mut().find(|c| c.id == "edit.copyFormat").unwrap();
copy.shortcut = copy_format_shortcut(web);
let registry = Registry::new(commands);
let mut seen = std::collections::BTreeSet::new();
for command in registry.all() {
for key in command.shortcut.split(" / ").filter_map(crate::keymap::normalize) {
assert!(seen.insert(key.clone()), "duplicate binding: {key}");
}
}
let keys = KeyMap::default();
let resolve = |key| keys.resolve(&registry, key).map(|c| c.id);
assert_eq!(resolve("Mod+Shift+C"), Some("edit.copyFormat"));
assert_eq!(resolve("Mod+Alt+Shift+C"), web.then_some("edit.copyFormat"));
assert_eq!(resolve("Mod+Shift+V"), Some("edit.pasteFormat"));
assert_eq!(resolve("Mod+Alt+Shift+V"), Some("edit.pasteText"));
if !cfg!(target_os = "macos") {
assert_eq!(resolve("Mod+Alt+C"), Some("text.copyright"));
}
}
}
#[test]
fn copy_format_web_alias_respects_user_removal_and_reassignment() {
let mut commands = super::super::registry().all().to_vec();
commands.iter_mut().find(|c| c.id == "edit.copyFormat").unwrap().shortcut = copy_format_shortcut(true);
let registry = Registry::new(commands);
let mut keys = KeyMap::default();
assert_eq!(keys.keys_for(&registry, "edit.copyFormat").first().map(String::as_str), Some("Mod+Alt+Shift+C"));
assert!(keys.remove(&registry, "edit.copyFormat", "Mod+Alt+Shift+C").unwrap());
assert!(keys.resolve(&registry, "Mod+Alt+Shift+C").is_none());
keys.assign(&registry, "format.bold", "Mod+Alt+Shift+C").unwrap();
let restored = KeyMap::from_value(&serde_json::to_value(&keys).unwrap());
assert_eq!(restored.resolve(&registry, "Mod+Alt+Shift+C").map(|c| c.id), Some("format.bold"));
assert_eq!(restored.resolve(&registry, "Mod+Shift+C").map(|c| c.id), Some("edit.copyFormat"));
}
}
+9 -9
View File
@@ -28,7 +28,7 @@ const ITEMS: &str = r#"[string | {"text": string, "level"?: 0-7, "color"?: RRGGB
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("insert.smartArt", "SmartArt", "Insert › Illustrations", insert).params(
CommandSpec::new("insert.smartArt", "Diagram", "Insert › Illustrations", insert).params(
r#"{"layout"?: "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn", "items"?: [string | {"text": string, "level"?: 0-7, "color"?: RRGGBB}], "colors"?: "accent1…accent6|colorful", "width"?: pt, "height"?: pt} (no items: sample items for the layout)"#,
),
CommandSpec::new("smartArt.items", "Edit Items", "SmartArt Design › Create Graphic", |s, v| {
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
let below = p::bool(v, "below").unwrap_or(false);
edit(s, move |g| {
if g.items.len() >= MAX_ITEMS {
return Err(CmdError::Failed(format!("a SmartArt graphic holds at most {MAX_ITEMS} items")));
return Err(CmdError::Failed(format!("a diagram holds at most {MAX_ITEMS} items")));
}
let Some(last) = g.items.len().checked_sub(1) else {
g.items.push(SmartArtItem::new(text.clone(), 0));
@@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"layout": "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn"}"#)
.when(has_smart_art),
CommandSpec::new("smartArt.colors", "Change Colors", "SmartArt Design › SmartArt Styles", |s, v| {
CommandSpec::new("smartArt.colors", "Change Colors", "SmartArt Design › Diagram Styles", |s, v| {
let colors = colors_of(v)?.ok_or_else(|| CmdError::Params(format!("`variant` is required: {COLORS}")))?;
edit(s, move |g| {
g.colors = colors;
@@ -130,7 +130,7 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"variant": "accent1|accent2|accent3|accent4|accent5|accent6|colorful"} (the items' own colours are dropped)"#)
.when(has_smart_art),
CommandSpec::new("smartArt.itemColor", "Item Color", "SmartArt Design › SmartArt Styles", |s, v| {
CommandSpec::new("smartArt.itemColor", "Item Color", "SmartArt Design › Diagram Styles", |s, v| {
let i = index(v, "index")?;
let color = match v.get("color") {
None | Some(Value::Null) => None,
@@ -174,9 +174,9 @@ fn has_smart_art(s: &Session) -> Option<&'static str> {
match selected(s) {
Some((_, InlineObject::Graphic { graphic, .. })) if graphic.smart_art.is_some() => None,
Some((_, InlineObject::Graphic { graphic, .. })) if graphic.kind == GraphicKind::Diagram => {
Some("this SmartArt was made in another program: WordCraft shows it but can't edit it")
Some("this diagram was made in another program: WordCraft shows it but can't edit it")
}
_ => Some("select a SmartArt graphic first"),
_ => Some("select a diagram first"),
}
}
@@ -299,9 +299,9 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
/// Change the selected graphic's model with `f`, then lay it out again at its size.
fn edit(s: &mut Session, f: impl Fn(&mut SmartArtSpec) -> Result<(), CmdError>) -> CmdResult {
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("select a SmartArt graphic first".into()))?;
let InlineObject::Graphic { w, h, graphic, .. } = obj else { return Err(CmdError::Disabled("select a SmartArt graphic first".into())) };
let mut spec = graphic.smart_art.as_deref().cloned().ok_or_else(|| CmdError::Disabled("this SmartArt graphic can't be edited".into()))?;
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("select a diagram first".into()))?;
let InlineObject::Graphic { w, h, graphic, .. } = obj else { return Err(CmdError::Disabled("select a diagram first".into())) };
let mut spec = graphic.smart_art.as_deref().cloned().ok_or_else(|| CmdError::Disabled("this diagram can't be edited".into()))?;
f(&mut spec)?;
let next = Arc::new(smart_art_graphic(spec, &s.doc.settings.theme_colors, w, h));
let out = json!({"smartArt": next.smart_art.as_deref()});
+10 -10
View File
@@ -19,24 +19,24 @@ const EFFECT_PARAMS: &str = r#"{"style"?: "solid"|"outlined"|"hollow"|"gradient"
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("insert.wordArt", "WordArt", "Insert › Text", insert).params(
r#"{"text"?: string, "style"?: WordArt style id (see format.textEffects), "transform"?: "textArchUp"|"textArchDown"|"textCircle"|"textWave1"|… (a DrawingML preset text warp)}"#,
CommandSpec::new("insert.wordArt", "Decorative Text", "Insert › Text", insert).params(
r#"{"text"?: string, "style"?: Decorative Text style id (see format.textEffects), "transform"?: "textArchUp"|"textArchDown"|"textCircle"|"textWave1"|… (a DrawingML preset text warp)}"#,
),
CommandSpec::new("format.textEffects", "Text Effects", "Home › Font", text_effects).params(EFFECT_PARAMS),
CommandSpec::new("wordArt.style", "WordArt Styles", "Shape Format › WordArt Styles", |s, v| {
CommandSpec::new("wordArt.style", "Decorative Text Styles", "Shape Format › Decorative Text Styles", |s, v| {
let id = p::req_str(v, "style")?;
text_effects(s, &json!({"style": id}))
})
.params(r#"{"style": "solid"|"outlined"|"hollow"|"gradient"|"shadowed"|"glowing"|"reflected"|"sunset"}"#),
CommandSpec::new("wordArt.textFill", "Text Fill", "Shape Format › WordArt Styles", |s, v| {
CommandSpec::new("wordArt.textFill", "Text Fill", "Shape Format › Decorative Text Styles", |s, v| {
text_effects(s, &json!({"fill": v.get("fill").or_else(|| v.get("color")).cloned().unwrap_or(Value::Null)}))
})
.params(r#"{"fill": "RRGGBB"|"none"|{"gradient": ["RRGGBB", …], "angle"?: deg}|null}"#),
CommandSpec::new("wordArt.textOutline", "Text Outline", "Shape Format › WordArt Styles", |s, v| {
CommandSpec::new("wordArt.textOutline", "Text Outline", "Shape Format › Decorative Text Styles", |s, v| {
text_effects(s, &json!({"outline": v.get("outline").cloned().unwrap_or_else(|| v.clone())}))
})
.params(r#"{"color"?: "RRGGBB"|null, "width"?: pt} or {"outline": "none"|null}"#),
CommandSpec::new("wordArt.transform", "Transform", "Shape Format › WordArt Styles", transform)
CommandSpec::new("wordArt.transform", "Transform", "Shape Format › Decorative Text Styles", transform)
.params(r#"{"preset": "textArchUp"|"textArchDown"|"textCircle"|"textWave1"|"textSlantUp"|"textSlantDown"|"textInflate"|"textDeflate"|"textChevron"|"textChevronInverted"|"textTriangle"|"textTriangleInverted"|… (any DrawingML preset text warp)|"none", "adj"?: n (the preset's adjust value)}"#),
]
}
@@ -186,7 +186,7 @@ fn text_effects(s: &mut Session, v: &Value) -> CmdResult {
let theme = theme(s);
let accent = theme.get(4).copied().unwrap_or(Rgb(0x15, 0x60, 0x82));
let style = match p::str(v, "style") {
Some(id) => Some(art_style(id, &theme).ok_or_else(|| CmdError::Params(format!("unknown WordArt style `{id}`")))?),
Some(id) => Some(art_style(id, &theme).ok_or_else(|| CmdError::Params(format!("unknown decorative text style `{id}`")))?),
None => None,
};
let clear = p::bool(v, "clear").unwrap_or(false);
@@ -244,11 +244,11 @@ fn transform(s: &mut Session, v: &Value) -> CmdResult {
Some(w)
}
};
let pos = warp_target(s).ok_or_else(|| CmdError::Disabled("select a shape or WordArt first".into()))?;
let pos = warp_target(s).ok_or_else(|| CmdError::Disabled("select a shape or decorative text first".into()))?;
let para = s.doc.para_mut(pos.story, &pos.path)?;
match para.object_at_mut(pos.off) {
Some(InlineObject::Shape { extra, .. }) => extra.warp = warp.clone(),
_ => return Err(CmdError::Disabled("select a shape or WordArt first".into())),
_ => return Err(CmdError::Disabled("select a shape or decorative text first".into())),
}
para.touch();
Ok(json!({"transform": warp.map(|w| w.preset)}))
@@ -269,7 +269,7 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
let text: String = p::str(v, "text").unwrap_or("Your text here").chars().filter(|c| !c.is_control()).take(1000).collect();
let text = if text.trim().is_empty() { "Your text here".to_string() } else { text };
let style = p::str(v, "style").unwrap_or("solid");
let fx = art_style(style, &theme(s)).ok_or_else(|| CmdError::Params(format!("unknown WordArt style `{style}`")))?;
let fx = art_style(style, &theme(s)).ok_or_else(|| CmdError::Params(format!("unknown decorative text style `{style}`")))?;
let warp = match p::str(v, "transform") {
None | Some("none") => None,
Some(id) => Some(TextWarp::new(id).ok_or_else(|| CmdError::Params(format!("unknown text warp `{id}`")))?),
+9
View File
@@ -200,3 +200,12 @@ fn wordart_reflection_and_outline_none_options() {
assert_eq!(outline.color, None);
assert_eq!(outline.width, 1.5);
}
#[test]
fn descriptive_feature_labels_keep_existing_command_ids() {
let s = Session::new(Document::new());
for (id, label) in [("insert.smartArt", "Diagram"), ("insert.wordArt", "Decorative Text"), ("wordArt.style", "Decorative Text Styles")] {
assert_eq!(s.registry.get(id).map(|spec| spec.label), Some(label));
}
assert!(s.registry.get("smartArt.colors").unwrap().location.starts_with("SmartArt Design › "));
}
+4 -2
View File
@@ -1,7 +1,7 @@
//! Rich Text Format adapter using craft-rtf.
pub use craft_rtf::{dttm_to_iso, iso_to_dttm};
pub use craft_rtf::doc::esc;
pub use craft_rtf::{dttm_to_iso, iso_to_dttm};
/// Import RTF bytes into WordCraft's document model.
pub fn import(bytes: &[u8]) -> Result<wordcraft_doc::Document, String> {
@@ -11,7 +11,9 @@ pub fn import(bytes: &[u8]) -> Result<wordcraft_doc::Document, String> {
/// Export a WordCraft document to RTF.
pub fn export(doc: &wordcraft_doc::Document) -> String {
let shared = wordcraft_docx::to_shared_model(doc).expect("model conversion");
let Ok(shared) = wordcraft_docx::to_shared_model(doc) else {
return String::new();
};
craft_rtf::doc::export(&shared)
}
+1 -1
View File
@@ -85,7 +85,7 @@ pub(super) fn clamped_scroll(before: egui::Vec2, delta: egui::Vec2, max: egui::V
/// Paint the selection with the Format Painter's formatting, when it is armed and something is
/// selected.
fn apply_format_painter(app: &mut WordApp) {
if app.session.painter.is_some() && !app.session.sel.is_collapsed() {
if app.session.painter.is_some() {
let _ = app.run("edit.pasteFormat", json!({}));
}
}
+1 -1
View File
@@ -64,7 +64,7 @@ impl InsertDialog {
InsertDialog::Columns(_) => "Columns",
InsertDialog::Symbol(_) => "Symbol",
InsertDialog::Field(_) => "Field",
InsertDialog::SmartArt(_) => "Insert SmartArt",
InsertDialog::SmartArt(_) => "Insert Diagram",
InsertDialog::ChartType(f) if f.change => "Change Chart Type",
InsertDialog::ChartType(_) => "Insert Chart",
InsertDialog::ChartData(_) => "Edit Data",
+10
View File
@@ -349,8 +349,15 @@ pub fn current() -> Lang {
CURRENT.get()
}
/// The contextual tab has an existing saved/control-channel identity. Keep it separate from
/// the source text displayed to users, so saved layouts and ui.tab calls keep working.
fn display_source(source: &str) -> &str {
if source == "SmartArt Design" { "Diagram Design" } else { source }
}
/// Does `lang` have a translation for this string? (English never does: it is the source.)
pub fn has(lang: Lang, s: &str) -> bool {
let s = display_source(s);
lang.i18n().get(lang.lang, "", s).is_some()
}
@@ -379,6 +386,7 @@ pub fn tc<'a>(context: &str, s: &'a str) -> &'a str {
/// [`tc`] in a given language.
pub fn trc<'a>(lang: Lang, context: &str, s: &'a str) -> &'a str {
let s = display_source(s);
#[cfg(test)]
if pseudo::on() {
return I18N.tc(craft_i18n::Lang::PSEUDO, context, s);
@@ -390,6 +398,7 @@ pub fn trc<'a>(lang: Lang, context: &str, s: &'a str) -> &'a str {
/// or English: for a word whose plain translation means something else here (the "Open"
/// paragraph-spacing preset is not the file verb).
pub fn get<'a>(context: &str, s: &'a str) -> Option<&'a str> {
let s = display_source(s);
#[cfg(test)]
if pseudo::on() {
return I18N.get(craft_i18n::Lang::PSEUDO, context, s);
@@ -419,6 +428,7 @@ pub fn lowercase(s: &str) -> String {
/// `s` in `lang`; strings without a translation come back unchanged.
pub fn tr(lang: Lang, s: &str) -> &str {
let s = display_source(s);
#[cfg(test)]
if pseudo::on() {
return I18N.t(craft_i18n::Lang::PSEUDO, s);
+16
View File
@@ -1299,3 +1299,19 @@ fn a_path_in_a_message_is_isolated_in_a_right_to_left_interface() {
set_current(Lang::EN);
assert_eq!(msg, "تعذر فتح \u{2066}C:\\\u{200E}ملفات\\\u{200E}تقرير\u{200E}.docx\u{2069}: \u{2068}bad\u{2069}");
}
#[test]
fn diagram_tab_display_keeps_the_legacy_identity() {
assert!(wordcraft_engine::ribbon::CONTEXTUAL_TABS.contains(&"SmartArt Design"));
assert_eq!(tr(Lang::EN, "SmartArt Design"), "Diagram Design");
assert_eq!(trc(Lang::EN, "preview", "SmartArt Design"), "Diagram Design");
assert_eq!(tr(Lang::EN, "Home"), "Home");
let fx = fixture_lang("x-fixture", "\tDiagram Design\tDiagram-FX\npreview\tDiagram Design\tDiagram-Preview-FX\n\tDiagram Styles\tStyles-FX\n");
assert!(has(fx, "SmartArt Design"));
assert_eq!(tr(fx, "SmartArt Design"), "Diagram-FX");
assert_eq!(trc(fx, "preview", "SmartArt Design"), "Diagram-Preview-FX");
set_current(fx);
assert_eq!(get("preview", "SmartArt Design"), Some("Diagram-Preview-FX"));
assert_eq!(location("SmartArt Design › Diagram Styles"), "Diagram-FX › Styles-FX");
set_current(Lang::EN);
}
+2 -2
View File
@@ -240,7 +240,7 @@ fn controls(tab: &str) -> Vec<Control> {
c("Pictures", "insert.picture"),
m("Shapes", "insert.shape"),
c("Icons", "insert.icon"),
c("SmartArt", "insert.smartArt"),
c("Diagram", "insert.smartArt"),
c("Chart", "insert.chart"),
d("Link", "link"),
d("Bookmark", "bookmark"),
@@ -251,7 +251,7 @@ fn controls(tab: &str) -> Vec<Control> {
m("Page Number", "insert.pageNumber"),
c("Text Box", "insert.textBox"),
c("Quick Parts", "insert.quickParts"),
m("WordArt", "insert.wordArt"),
m("Decorative Text", "insert.wordArt"),
m("Drop Cap", "insert.dropCap"),
c("Signature Line", "insert.signatureLine"),
d("Date & Time", "dateTime"),
+1 -1
View File
@@ -53,7 +53,7 @@ pub(super) fn smart_art_design(app: &mut WordApp, ui: &mut Ui) {
}
});
});
group(ui, "SmartArt Styles", None, app, |ui, app| {
group(ui, "Diagram Styles", None, app, |ui, app| {
menu_button(
ui,
app,
+7 -4
View File
@@ -98,7 +98,7 @@ pub(super) fn insert(app: &mut WordApp, ui: &mut Ui) {
});
stack(ui, |ui| {
small(ui, app, "icons", Some("Icons"), "Icons", crate::widgets::ButtonAction::new("insert.icon", json!({}), false));
small(ui, app, "smartArt", Some("SmartArt"), "SmartArt", crate::widgets::ButtonAction::new("insert.smartArt", json!({}), false));
small(ui, app, "smartArt", Some("Diagram"), "Diagram", crate::widgets::ButtonAction::new("insert.smartArt", json!({}), false));
small(ui, app, "chart", Some("Chart"), "Chart", crate::widgets::ButtonAction::new("insert.chart", json!({}), false));
});
});
@@ -178,9 +178,12 @@ pub(super) fn insert(app: &mut WordApp, ui: &mut Ui) {
mi(ui, app, "Field…", "ui.dialog", json!({"name": "field"}));
},
);
menu_button(ui, app, crate::widgets::MenuButtonOptions::new("wordArt", Some("WordArt"), "WordArt", false, ""), |ui, app| {
crate::wordart_menus::style_gallery(ui, app, "insert.wordArt")
});
menu_button(
ui,
app,
crate::widgets::MenuButtonOptions::new("wordArt", Some("Decorative Text"), "Decorative Text", false, ""),
|ui, app| crate::wordart_menus::style_gallery(ui, app, "insert.wordArt"),
);
menu_button(
ui,
app,
+2 -2
View File
@@ -102,8 +102,8 @@ pub(super) fn shape_format(app: &mut WordApp, ui: &mut Ui) {
});
});
}
group(ui, "WordArt Styles", None, app, |ui, app| {
menu_button(ui, app, crate::widgets::MenuButtonOptions::new("wordArt", Some("Styles"), "WordArt Styles", true, ""), |ui, app| {
group(ui, "Decorative Text Styles", None, app, |ui, app| {
menu_button(ui, app, crate::widgets::MenuButtonOptions::new("wordArt", Some("Styles"), "Decorative Text Styles", true, ""), |ui, app| {
crate::wordart_menus::style_gallery(ui, app, "wordArt.style")
});
stack(ui, |ui| {
+4 -1
View File
@@ -578,12 +578,15 @@ fn page_setup_launcher_opens_the_dialog() {
#[test]
fn smart_art_picker_inserts_and_design_tab_follows_the_selection() {
let mut app = crate::WordApp::new(Session::new(wordcraft_doc::Document::new()), Default::default());
// Insert › SmartArt without a layout opens the picker.
// Insert › Diagram without a layout opens the picker.
let _ = app.run("insert.smartArt", json!({}));
assert_eq!(app.dialog.as_ref().map(crate::dialogs::Dialog::name), Some("insertSmartArt"));
app.dialog = None;
app.run("insert.smartArt", json!({"layout": "cycle"})).unwrap();
assert!(contextual_tabs(&app.session).contains(&"SmartArt Design"));
app.run("ui.tab", json!({"tab": "SmartArt Design"})).unwrap();
assert_eq!(app.ui.tab, "SmartArt Design");
assert_eq!(crate::i18n::tr(crate::i18n::Lang::EN, &app.ui.tab), "Diagram Design");
app.run("smartArt.textPane", json!({})).unwrap();
assert!(app.session.view.smart_art_pane);
app.session.run("select.collapse", &json!({"end": true})).unwrap();
+112
View File
@@ -8070,6 +8070,118 @@ fn focus_hides_the_ribbon_and_escape_brings_it_back() {
assert!(h.state().session.view.read_mode && shown(&h, "Focus"), "Immersive Reader keeps the status bar");
}
#[test]
fn format_painter_single_click_formats_the_target_word_and_finishes() {
let mut a = app();
let text = "ORIGEM formatada e DESTINO comum";
a.run("text.insert", json!({"text": text})).unwrap();
let mut start = a.session.sel.focus.clone();
start.off = 0;
let mut end = start.clone();
end.off = "ORIGEM".len();
a.run("caret.set", json!({"pos": start})).unwrap();
a.run("caret.set", json!({"pos": end, "extend": true})).unwrap();
for id in ["format.bold", "format.italic", "format.underline"] {
a.run(id, json!({})).unwrap();
}
let source = a.session.doc.para_at(&start).unwrap().props_of_char(0).clone();
a.run("edit.formatPainter", json!({})).unwrap();
let mut target = start.clone();
target.off = text.find("DESTINO").unwrap() + 2;
let mut h = harness(a);
h.run_steps(6);
let caret = h.state_mut().session.layout().caret_on(&target, 0).unwrap();
let p = canvas::page_to_screen(h.state_mut(), caret.page, caret.x, caret.top + caret.height * 0.5).unwrap();
h.event(egui::Event::PointerMoved(p));
h.run_steps(1);
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
h.run_steps(1);
}
assert!(h.state().session.painter.is_none(), "one click consumes the non-sticky painter");
let para = h.state().session.doc.para_at(&target).unwrap();
let got = para.props_of_char(target.off);
assert_eq!((&got.bold, &got.italic, &got.underline), (&source.bold, &source.italic, &source.underline));
assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), text);
assert_ne!(para.props_of_char(text.find("DESTINO").unwrap() + "DESTINO".len()).bold, source.bold, "trailing space stays plain");
assert_ne!(para.props_of_char(text.len() - 2).bold, source.bold, "surrounding text stays plain");
}
#[test]
fn format_painter_double_click_drag_and_copy_paste_preserve_target_scope() {
for gesture in ["double", "drag", "commands"] {
let mut a = app();
let text = "ORIGEM formatada e DESTINO comum";
a.run("text.insert", json!({"text": text})).unwrap();
let mut source_start = a.session.sel.focus.clone();
source_start.off = 0;
let mut source_end = source_start.clone();
source_end.off = "ORIGEM".len();
a.run("caret.set", json!({"pos": source_start})).unwrap();
a.run("caret.set", json!({"pos": source_end, "extend": true})).unwrap();
for id in ["format.bold", "format.italic", "format.underline"] {
a.run(id, json!({})).unwrap();
}
let source = a.session.doc.para_at(&source_start).unwrap().props_of_char(0).clone();
let mut target_start = source_start.clone();
target_start.off = text.find("DESTINO").unwrap();
let mut target_end = target_start.clone();
target_end.off += "DESTINO".len();
a.run(if gesture == "commands" { "edit.copyFormat" } else { "edit.formatPainter" }, json!({})).unwrap();
let mut h = harness(a);
h.run_steps(6);
if gesture == "commands" {
h.state_mut().run("caret.set", json!({"pos": target_start})).unwrap();
h.state_mut().run("caret.set", json!({"pos": target_end, "extend": true})).unwrap();
h.state_mut().run("edit.pasteFormat", json!({})).unwrap();
} else {
let mut press = target_start.clone();
if gesture == "double" {
press.off += 2;
}
let caret = h.state_mut().session.layout().caret_on(&press, 0).unwrap();
let start = canvas::page_to_screen(h.state_mut(), caret.page, caret.x, caret.top + caret.height * 0.5).unwrap();
let caret = h.state_mut().session.layout().caret_on(&target_end, 0).unwrap();
let end = canvas::page_to_screen(h.state_mut(), caret.page, caret.x, caret.top + caret.height * 0.5).unwrap();
h.event(egui::Event::PointerMoved(start));
h.run_steps(1);
let clicks = if gesture == "double" { 2 } else { 1 };
for _ in 0..clicks {
h.event(egui::Event::PointerButton {
pos: start,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::NONE,
});
h.run_steps(1);
let release = if gesture == "drag" {
h.event(egui::Event::PointerMoved(end));
h.run_steps(2);
end
} else {
start
};
h.event(egui::Event::PointerButton {
pos: release,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::NONE,
});
h.run_steps(1);
}
}
assert!(h.state().session.painter.is_none(), "{gesture}: one-use painter must disarm");
let para = h.state().session.doc.para_at(&target_start).unwrap();
for off in target_start.off..target_end.off {
let got = para.props_of_char(off);
assert_eq!((&got.bold, &got.italic, &got.underline), (&source.bold, &source.italic, &source.underline), "{gesture}: target offset {off}");
}
assert_ne!(para.props_of_char(target_end.off).bold, source.bold, "{gesture}: trailing space stays plain");
assert_ne!(para.props_of_char(text.len() - 2).bold, source.bold, "{gesture}: surrounding text stays plain");
assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), text);
}
}
/// Issue #2716: taking documents off the recent list is final — the open document must not be
/// pushed back by the after-command re-add — and the change goes to the settings at once, so
/// it survives the app being killed. Junk params return errors, never panics.
+193
View File
@@ -0,0 +1,193 @@
//! Guard the production display surfaces without banning compatibility identifiers or notices.
use std::path::{Path, PathBuf};
const BANNED: &[&str] = &["SmartArt", "WordArt"];
const ALLOWED: &[&str] = &["SmartArt Design"];
const ROOTS: &[&str] = &["src", "../engine/src/cmd"];
fn is_word(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn banned_display(text: &str) -> bool {
if ALLOWED.contains(&text) || text.strip_prefix("SmartArt Design › ").is_some_and(|group| !banned_display(group)) {
return false;
}
BANNED.iter().any(|banned| {
text.match_indices(banned)
.any(|(at, _)| !text[..at].chars().next_back().is_some_and(is_word) && !text[at + banned.len()..].chars().next().is_some_and(is_word))
})
}
// Read ordinary and raw Rust string literals, ignoring comments. No compiler dependency is
// needed, so this guard can also run by itself when a full application build is unavailable.
#[allow(clippy::cognitive_complexity)] // This small test-only lexer keeps raw-string/comment state together.
fn literals(source: &str) -> Vec<String> {
let chars: Vec<char> = source.chars().collect();
let mut out = Vec::new();
let mut at = 0;
let mut test_module = false;
let mut test_depth = 0_usize;
while at < chars.len() {
if chars.get(at..at + 12) == Some(&['#', '[', 'c', 'f', 'g', '(', 't', 'e', 's', 't', ')', ']']) {
at += 12;
let rest: String = chars[at..].iter().collect();
if rest.trim_start().starts_with("mod ") {
test_module = true;
}
} else if chars.get(at..at + 2) == Some(&['/', '/']) {
while at < chars.len() && chars[at] != '\n' {
at += 1;
}
} else if chars.get(at..at + 2) == Some(&['/', '*']) {
at += 2;
let mut depth = 1;
while at < chars.len() && depth > 0 {
if chars.get(at..at + 2) == Some(&['/', '*']) {
depth += 1;
at += 2;
} else if chars.get(at..at + 2) == Some(&['*', '/']) {
depth -= 1;
at += 2;
} else {
at += 1;
}
}
} else if chars[at] == 'r' && matches!(chars.get(at + 1), Some('"' | '#')) {
let start = at;
at += 1;
while chars.get(at) == Some(&'#') {
at += 1;
}
let hashes = at - start - 1;
if chars.get(at) != Some(&'"') {
continue;
}
at += 1;
let begin = at;
while at < chars.len() {
if chars[at] == '"' && (1..=hashes).all(|n| chars.get(at + n) == Some(&'#')) {
break;
}
at += 1;
}
if !test_module {
out.push(chars[begin..at].iter().collect());
}
at += 1 + hashes;
} else if chars[at] == '"' {
at += 1;
let mut text = String::new();
while at < chars.len() {
let c = chars[at];
at += 1;
if c == '"' {
break;
}
if c == '\\' && at < chars.len() {
text.push(chars[at]);
at += 1;
} else {
text.push(c);
}
}
if !test_module {
out.push(text);
}
} else if chars[at] == '\'' && chars.get(at + 1) == Some(&'\\') && chars.get(at + 3) == Some(&'\'') {
at += 4; // An escaped character literal must not start a string.
} else if chars[at] == '\'' && chars.get(at + 2) == Some(&'\'') {
at += 3; // A character literal such as '"', not the start of a string.
} else {
if test_module {
match chars[at] {
'{' => test_depth += 1,
'}' => {
test_depth = test_depth.saturating_sub(1);
if test_depth == 0 {
test_module = false;
}
}
';' if test_depth == 0 => test_module = false,
_ => {}
}
}
at += 1;
}
}
out
}
fn check_tree(path: &Path, failures: &mut Vec<String>) {
if path.is_dir() {
for entry in std::fs::read_dir(path).expect("source directory") {
let path = entry.expect("source entry").path();
if path.file_name().is_some_and(|name| name == "tests") {
continue;
}
check_tree(&path, failures);
}
} else if path.extension().is_some_and(|ext| ext == "rs") {
let name = path.file_name().expect("source filename").to_string_lossy();
if name == "tests.rs" || name.ends_with("_tests.rs") || path.ends_with("src/i18n/audit.rs") {
// i18n.rs includes audit only under cfg(test); it names legacy tabs to exercise them.
return;
}
let source = std::fs::read_to_string(path).expect("UTF-8 Rust source");
// Only annotated test modules are skipped. Production following a test-only import or
// pseudo-locale branch still needs this guard.
for text in literals(&source) {
if banned_display(&text) {
failures.push(format!("{}: {text:?}", path.display()));
}
}
}
}
#[test]
fn production_display_names_stay_neutral() {
let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let mut failures = Vec::new();
for root in ROOTS {
let path = base.join(root);
assert!(path.exists(), "guard source root must exist: {}", path.display());
check_tree(&path, &mut failures);
}
assert!(failures.is_empty(), "Banned display names:\n{}", failures.join("\n"));
}
#[test]
fn guard_distinguishes_labels_from_identifiers_and_comments() {
for name in BANNED {
assert!(banned_display(name), "{name}");
}
for name in ALLOWED {
assert!(!banned_display(name), "{name}");
}
assert!(!banned_display("insertSmartArt"));
assert!(!banned_display("wordArt.style"));
assert!(!banned_display("SmartArt Design › Diagram Styles"));
assert!(banned_display("SmartArt Design › WordArt Styles"));
assert!(!banned_display("insert.smartArt"));
assert_eq!(literals("// \"ignored\"\n/* \"ignored\" */ \"label\""), ["label"]);
assert_eq!(literals("r#\"raw label\"#"), ["raw label"]);
}
#[test]
fn guard_scans_production_after_test_only_items_and_parses_raw_labels() {
let source = r###"#[cfg(test)] mod tests { let _ = "SmartArt"; }
#[cfg(test)] use fixtures::Helper;
fn production() { let _ = r#"WordArt"#; }
"###;
assert_eq!(literals(source), ["WordArt"]);
assert!(literals(source).iter().any(|label| banned_display(label)));
assert_eq!(literals("#[cfg(test)] mod tests; fn live() { let _ = \"SmartArt\"; }"), ["SmartArt"]);
}
#[test]
fn guard_skips_escaped_character_literals_without_hiding_labels() {
let source = r#"let quote = '\"'; let label = "WordArt"; // "ignored""#;
assert_eq!(literals(source), ["WordArt"]);
assert!(literals(source).iter().any(|label| banned_display(label)));
assert_eq!(literals(r#"let quote = '\"'; // "WordArt""#), Vec::<String>::new());
}
+24
View File
@@ -0,0 +1,24 @@
# Feature naming
WordCraft uses **Diagram**, **Diagram Design**, **Diagram Styles**, and **Decorative Text** for its own creation and editing features. Existing command IDs, public Rust type names and file-format mappings remain unchanged.
## Stable identities
The saved/control-channel contextual tab key `SmartArt Design` remains stable. Display-time localization maps that key to Diagram Design; dispatch and saved ribbon layouts do not depend on the new visible name. `insert.smartArt`, `smartArt.*`, `insert.wordArt`, `wordArt.*`, dialog IDs and icon IDs remain stable.
OOXML diagram/WordArt mappings, existing document-owned names and attribution are untouched. This is a naming change, not a legal determination.
## Translation dependency and readiness
WordCraft embeds shared catalogs from craft-i18n at `craft-i18n.rev`. The companion catalog PR must merge, then this pin must be advanced to its upstream commit before this app draft is marked ready. Until then, newly renamed labels can fall back to English. Do not merge the app change first.
The companion includes localized feature labels for every offered non-English language. Native-speaker review, the normal engine/UI/OOXML tests, visual ribbon/dialog checks and full CI remain required.
## Regression guards
`crates/ui-egui/tests/display_names.rs` scans production UI and engine command
strings for the former feature labels, while retaining the exact saved tab identity
and its command locations. It does not ban public Rust types, command IDs, document
content or the external-format parity catalog. The companion catalog regression
checks all renamed translations and pins the 17 shared GridCraft language/script
variants. Missing translations continue to fall back to English.
+4
View File
@@ -413,3 +413,7 @@ labels unambiguous. Those extra-number assignments have not been compared with W
| 2026-10-25 | trivial | Customize Ribbon, Quick Access Toolbar and Customize Keyboard pickers: filter-as-you-type command search across all commands (label or id, case-insensitive); clearing restores the category scope (#1845) |
| 2026-10-29 | trivial | Table Properties: the Row, Column and Cell settings apply to every selected row, column and cell, as in Word, not to the caret's alone, so selecting the whole table and unticking Allow row to break across pages keeps every row whole; Allow row to break and Repeat as header row show as mixed when the selected rows differ and change nothing until clicked; the row-height rule box has an accessible name (#4857) |
| 2026-10-27 | trivial | File › Options › Proofing › AutoCorrect Options opens its dialog with no document open (Start screen); it did nothing there (#4585) |
### Format Painter integration refresh, 2026-10-30
Single-click release applies the armed painter to the target word without trailing spaces and disarms it. Explicit selections retain their scope (#1455, partial). Source and UI regressions are retained after the canvas/input module split. Earlier Windows pointer and browser qualification are historical; current-head hosted validation is pending, and native shortcut/Mint qualification is not newly claimed.
@@ -128,6 +128,6 @@ modules:
# the two equal).
- type: git
url: https://github.com/storytold/craft-i18n
commit: 1e515422afdda2f317af430c1f9d68a5f83935b4
commit: e56d26244bbac099bfd42e23fb12f78a4be418a7
dest: craft-i18n
- cargo-sources.json
+1 -1
View File
@@ -60,7 +60,7 @@ pub const TABLE: &[(&str, Class)] = &[
/// The L0 foundation is a small chain: `raster` builds on `color` and
/// `geom`, which the §3 diagram draws on one line. The GPU backend (`gpu`)
/// reuses the CPU reference (`compose`) for LUTs and parity tests.
pub const INTRA_LAYER_ORDER: &[&[&str]] = &[&["doc", "fonts"]];
pub const INTRA_LAYER_ORDER: &[&[&str]] = &[&["doc", "fonts"], &["docx", "formats"]];
fn intra_layer_allowed(from: &str, to: &str) -> bool {
let (from, to) = (short_name(from), short_name(to));