Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ The release run heads these entries with the version and opens a fresh

- `Text::set_style` and the edit op `setTextStyle {id, style}` write bold,
italic, underline, strikethrough, highlight, colour and size onto a run of
an odf document. The other engines refuse it.
an odf, docx or pptx document. The other engines refuse it.

- A `.docx` run's shading (`w:shd`) renders as its background where no
highlight paints over it.

- A run that is both underlined and struck through renders both lines. The
page wrote two `text-decoration` declarations, and the second replaced the
Expand Down
1 change: 1 addition & 0 deletions docs/design/document-editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,7 @@ Each step is a pull request that builds and tests on its own.
LibreOffice reopen of the saved file is the oracle. **Landed.**
3. **docx and pptx.** The run cut, the `w:rPr` order, `w:shd` on the read
side, the `a:rPr` children. The same cases, over Word and Impress fixtures.
**Landed.**
4. **The browser.** `format()`, `onSelectionChange`, the four input types,
the word rule for a collapsed caret, and a check page in
`test/browser/text` asserting the log of each gesture.
Expand Down
109 changes: 93 additions & 16 deletions src/odr/internal/ooxml/ooxml_util.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,17 @@
#include <odr/internal/util/string_util.hpp>
#include <odr/internal/xml/xml_util.hpp>

#include <algorithm>
#include <array>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>

#include <fmt/format.h>

namespace odr::internal {

Expand Down Expand Up @@ -99,31 +106,101 @@ ooxml::read_string_attribute(const pugi::xml_attribute attribute) {
return attribute.value();
}

std::optional<Color>
ooxml::read_color_attribute(const pugi::xml_attribute attribute) {
// color codes from http://officeopenxml.com/WPtextShading.php
// rgb values suggested by chatgpt
static const std::unordered_map<std::string, Color> color_map{
{"black", {0, 0, 0}}, {"blue", {0, 112, 192}},
{"cyan", {0, 176, 240}}, {"darkBlue", {0, 32, 96}},
{"darkCyan", {0, 97, 133}}, {"darkGray", {64, 64, 64}},
{"darkGreen", {0, 128, 0}}, {"darkMagenta", {112, 48, 160}},
{"darkRed", {192, 0, 0}}, {"darkYellow", {128, 96, 0}},
{"green", {0, 176, 80}}, {"lightGray", {191, 191, 191}},
{"magenta", {255, 0, 255}}, {"red", {255, 0, 0}},
{"white", {255, 255, 255}}, {"yellow", {255, 255, 0}},
namespace {

/// [ECMA-376] 17.18.40 `ST_HighlightColor`, less `none`. The rgb values are
/// what Word paints for each name.
constexpr std::array<std::pair<std::string_view, std::uint32_t>, 16>
highlight_colors{{
{"black", 0x000000},
{"blue", 0x0070c0},
{"cyan", 0x00b0f0},
{"darkBlue", 0x002060},
{"darkCyan", 0x006185},
{"darkGray", 0x404040},
{"darkGreen", 0x008000},
{"darkMagenta", 0x7030a0},
{"darkRed", 0xc00000},
{"darkYellow", 0x806000},
{"green", 0x00b050},
{"lightGray", 0xbfbfbf},
{"magenta", 0xff00ff},
{"red", 0xff0000},
{"white", 0xffffff},
{"yellow", 0xffff00},
}};

} // namespace

pugi::xml_node
ooxml::insert_in_sequence(pugi::xml_node parent, const char *name,
const std::span<const std::string_view> order) {
const auto rank = [&](const std::string_view child_name) {
const auto it = std::ranges::find(order, child_name);
return it == std::end(order)
? order.size()
: static_cast<std::size_t>(it - std::begin(order));
};
const std::size_t own_rank = rank(name);
for (const pugi::xml_node child : parent.children()) {
if (rank(child.name()) > own_rank) {
return parent.insert_child_before(name, child);
}
}
return parent.append_child(name);
}

std::string ooxml::hex_color(const Color &color) {
return fmt::format("{:06X}", color.rgb());
}

std::optional<std::string_view> ooxml::highlight_name(const Color &color) {
const auto it =
std::ranges::find(highlight_colors, color.rgb(),
&std::pair<std::string_view, std::uint32_t>::second);
if (it == std::end(highlight_colors)) {
return {};
}
return it->first;
}

double ooxml::points(const Measure &length) {
const std::string &unit = length.unit().name();
if (unit == "pt") {
return length.magnitude();
}
if (unit == "px") {
return length.magnitude() * 0.75;
}
if (unit == "in") {
return length.magnitude() * 72.0;
}
if (unit == "cm") {
return length.magnitude() * 72.0 / 2.54;
}
if (unit == "mm") {
return length.magnitude() * 72.0 / 25.4;
}
if (unit == "pc") {
return length.magnitude() * 12.0;
}
throw std::invalid_argument("no fixed size in points: " + length.to_string());
}

std::optional<Color>
ooxml::read_color_attribute(const pugi::xml_attribute attribute) {
if (!attribute) {
return {};
}
const char *value = attribute.value();
if (std::strcmp("auto", value) == 0 || std::strcmp("none", value) == 0) {
return {};
}
if (const auto color_map_it = color_map.find(value);
color_map_it != std::end(color_map)) {
return color_map_it->second;
if (const auto it =
std::ranges::find(highlight_colors, std::string_view(value),
&std::pair<std::string_view, std::uint32_t>::first);
it != std::end(highlight_colors)) {
return Color::from_rgb(it->second);
}
if (std::strlen(value) == 6) {
const std::uint32_t color = std::strtoull(value, nullptr, 16);
Expand Down
13 changes: 13 additions & 0 deletions src/odr/internal/ooxml/ooxml_util.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include <odr/internal/xml/xml_tree_edit.hpp>

#include <optional>
#include <span>
#include <string>
#include <string_view>
#include <unordered_map>
Expand Down Expand Up @@ -36,6 +37,18 @@ xml::NodeSpan write_text_nodes(pugi::xml_node parent, pugi::xml_node before,
const std::string &text,
std::string_view prefix);

/// Inserts a child @p name into @p parent at its place in the schema
/// sequence @p order; a child the sequence does not name ranks last.
pugi::xml_node insert_in_sequence(pugi::xml_node parent, const char *name,
std::span<const std::string_view> order);
/// `RRGGBB`, as `w:color/@w:val` and `a:srgbClr/@val` spell one.
std::string hex_color(const Color &color);
/// The `w:highlight` name of @p color, where it is one of the sixteen
/// ([ECMA-376] 17.18.40).
std::optional<std::string_view> highlight_name(const Color &color);
/// @p length in points; refuses a unit that has no fixed size.
double points(const Measure &length);

std::optional<std::string> read_string_attribute(pugi::xml_attribute);
std::optional<Color> read_color_attribute(pugi::xml_attribute);
std::optional<Measure> read_half_point_attribute(pugi::xml_attribute);
Expand Down
13 changes: 7 additions & 6 deletions src/odr/internal/ooxml/presentation/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,13 @@ Coverage is in [`README.md`](README.md). Foundational gaps, roughly by value:
3. **Table cell styles unresolved.** Tables are wired (grid, spans, covered
cells, column widths/row heights), but `a:tcPr` (fills, borders, margins)
is not translated.
4. **Editing is text-content and the structure a text edit needs**, the same
surface `.docx` has: set a run's text, put a run beside one, remove an
element, and split, merge or insert a paragraph. The dom half is
`xml::TreeEditor`, shared with odf and ooxml text — only the tag names
differ, and those come from the nodes. No style editing, and no editing of
a shape, a picture or a table's furniture.
4. **Editing is runs, paragraphs and the seven text properties**, the same
surface `.docx` has. The dom half is `xml::TreeEditor`, shared with odf and
ooxml text — only the tag names differ. `text_set_style` cuts the `a:r`
around the run and writes the toggles and the size as `a:rPr` attributes,
the colour as `a:solidFill` and the highlight as `a:highlight`, each at its
place in the `CT_TextCharacterProperties` sequence. No other style editing,
and no editing of a shape, a picture or a table's furniture.

`save` re-serialises the slide parts and copies the rest of the package
through as bytes, so a part we never parsed survives untouched. The slides
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@
#include <odr/internal/xml/xml_util.hpp>
#include <odr/internal/zip/zip_archive.hpp>

#include <array>
#include <cmath>
#include <iterator>
#include <sstream>
#include <stdexcept>
#include <string_view>

namespace odr::internal::ooxml::presentation {

Expand Down Expand Up @@ -189,6 +192,68 @@ const ElementRegistry &Document::element_registry() const {
namespace {

using TreeEditor = xml::TreeEditor<ElementRegistry>;

/// [ECMA-376] 21.1.2.3.9 `CT_TextCharacterProperties`: the children are a
/// sequence, one fill among them.
constexpr std::array<std::string_view, 22> run_property_order{
"a:ln", "a:noFill", "a:solidFill", "a:gradFill",
"a:blipFill", "a:pattFill", "a:grpFill", "a:effectLst",
"a:effectDag", "a:highlight", "a:uLnTx", "a:uLn",
"a:uFillTx", "a:uFill", "a:latin", "a:ea",
"a:cs", "a:sym", "a:hlinkClick", "a:hlinkMouseOver",
"a:rtl", "a:extLst"};

constexpr std::array<const char *, 6> fill_names{"a:noFill", "a:solidFill",
"a:gradFill", "a:blipFill",
"a:pattFill", "a:grpFill"};

/// Writes the set fields of @p style into an `a:rPr`: the toggles and the
/// size as attributes, the colour and the highlight as children.
void write_run_properties(pugi::xml_node properties, const TextStyle &style) {
const auto attribute = [&](const char *name, const std::string &value) {
pugi::xml_attribute attr = properties.attribute(name);
if (!attr) {
attr = properties.append_attribute(name);
}
attr.set_value(value.c_str());
};
const auto solid = [&](const char *name, const Color &color) {
insert_in_sequence(properties, name, run_property_order)
.append_child("a:srgbClr")
.append_attribute("val")
.set_value(hex_color(color).c_str());
};

if (style.font_weight.has_value()) {
attribute("b", *style.font_weight == FontWeight::bold ? "1" : "0");
}
if (style.font_style.has_value()) {
attribute("i", *style.font_style == FontStyle::italic ? "1" : "0");
}
if (style.font_underline.has_value()) {
attribute("u", *style.font_underline ? "sng" : "none");
}
if (style.font_line_through.has_value()) {
attribute("strike", *style.font_line_through ? "sngStrike" : "noStrike");
}
if (style.font_size.has_value()) {
attribute("sz",
std::to_string(std::lround(points(*style.font_size) * 100.0)));
}
if (style.font_color.has_value()) {
for (const char *name : fill_names) {
properties.remove_child(name);
}
solid("a:solidFill", *style.font_color);
}
if (style.background_color.has_value()) {
// there is no `none`: an absent highlight is none
properties.remove_child("a:highlight");
if (style.background_color->alpha != 0) {
solid("a:highlight", *style.background_color);
}
}
}
using xml::NodeSpan;

using AdapterBase = internal::RegistryElementAdapter<
Expand Down Expand Up @@ -310,6 +375,25 @@ class ElementAdapter final : public AdapterBase {
void element_remove(const ElementIdentifier element_id) const override {
TreeEditor(*m_registry).remove(element_id);
}
/// Cuts the `a:r` around the run, each part keeping the `a:rPr`, and
/// writes into the part that holds the run.
void text_set_style(const ElementIdentifier element_id,
const TextStyle &style) const override {
const ElementIdentifier parent_id = element_parent(element_id);
if (parent_id == null_element_id ||
element_type(parent_id) != ElementType::span) {
throw std::invalid_argument("the run sits in no a:r");
}
const ElementIdentifier run_id =
TreeEditor(*m_registry).isolate(element_id);
pugi::xml_node run_node = get_node(run_id);
pugi::xml_node properties = run_node.child("a:rPr");
if (!properties) {
// the schema wants it ahead of the text
properties = run_node.prepend_child("a:rPr");
}
write_run_properties(properties, style);
}

[[nodiscard]] ElementIdentifier
paragraph_split(const ElementIdentifier element_id,
Expand Down
16 changes: 12 additions & 4 deletions src/odr/internal/ooxml/text/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,15 @@ separately from its `ResolvedStyle` so an inherited one is seen.
string and splices `w:t` (with `xml:space="preserve"` for spaces) / `w:tab` nodes
into the live `m_document_xml`, updating the registry's node pointers. `save`
re-zips the package, re-serialising **only** `word/document.xml` from the mutated
DOM; everything else is byte-copied. No structural edits; `save(path, password)`
throws (no re-encryption).
DOM; everything else is byte-copied. `save(path, password)` throws (no
re-encryption).

`text_set_style` cuts the `w:r` around the run (`TreeEditor::isolate`) and
writes the seven text properties into its `w:rPr`. `CT_RPr` is a sequence
Word enforces, so each property lands at its rank (`run_property_order`) and
replaces an existing one whole. A highlight is `w:highlight` for one of the
sixteen names and a `w:shd` shading otherwise; the reader takes `w:shd` where
no highlight names a colour.

## Module layout

Expand All @@ -106,8 +113,9 @@ Style/element coverage is in [`README.md`](README.md). Foundational gaps:
nothing else; symbol-font bullets are mapped to Unicode by a small table and
otherwise fall back to the level's default shape, since the private-use code
points Word writes render only in Symbol / Wingdings.
2. **No structural editing**; save doesn't stream (buffers document.xml, re-zips
the whole package); no re-encryption on save.
2. **Editing is runs, paragraphs and the seven text properties**; save
doesn't stream (buffers document.xml, re-zips the whole package); no
re-encryption on save.
3. **Theme fonts unhandled.** `w:rFonts w:asciiTheme="minorHAnsi"` (etc.) is
ignored — only literal `w:ascii` names are read (README example
`Sample large docx.docx`).
Expand Down
Loading
Loading