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: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ The release run heads these entries with the version and opens a fresh
named glyph variant such as `hyphen.case` is the one painted: a simple font's
`/Encoding` names select through the font program's charset.
- A pdf whose Flate streams omit the ADLER32 trailer opens.
- A `.docx` is spaced the way word spaces it: paragraph spacing before and after
and line height (`w:spacing`), the `w:contextualSpacing` that keeps a list
tight, and table row heights (`w:trHeight`, as a minimum height).
- A `.docx` table follows its table style: the paragraph and text properties
`w:tblStyle` names reach everything in the table.

## v6.8.0 - 2026-08-18

Expand Down
2 changes: 1 addition & 1 deletion src/odr/internal/html/document_style.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ html::translate_table_column_style(const TableColumnStyle &table_column_style) {
std::string
html::translate_table_row_style(const TableRowStyle &table_row_style) {
std::string result;
// TODO that does not work with HTML; height would need to be applied to the
// html takes a row height as a minimum; capping one would have to go on the
// cells
if (const std::optional<Measure> height = table_row_style.height;
height.has_value()) {
Expand Down
23 changes: 23 additions & 0 deletions src/odr/internal/ooxml/ooxml_util.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,29 @@ std::optional<Measure> ooxml::read_width_attribute(const pugi::xml_node node) {
return {};
}

/// [ECMA-376] 17.17.4 ST_OnOff, as an attribute that is off when absent.
bool ooxml::read_on_off_attribute(const pugi::xml_attribute attribute) {
if (!attribute) {
return false;
}
const char *value = attribute.value();
return std::strcmp("0", value) != 0 && std::strcmp("false", value) != 0 &&
std::strcmp("off", value) != 0;
}

/// [ECMA-376] 17.17.4 ST_OnOff, as an element that is on unless `w:val` says
/// otherwise.
bool ooxml::read_on_off_attribute(const pugi::xml_node node) {
if (!node) {
return false;
}
const pugi::xml_attribute value = node.attribute("w:val");
if (!value) {
return true;
}
return read_on_off_attribute(value);
}

bool ooxml::read_line_attribute(const pugi::xml_node node) {
if (!node) {
return false;
Expand Down
2 changes: 2 additions & 0 deletions src/odr/internal/ooxml/ooxml_util.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ std::optional<Measure> read_emus_attribute(pugi::xml_attribute);
std::optional<Measure> read_twips_attribute(pugi::xml_attribute);
std::optional<Measure> read_pct_attribute(pugi::xml_attribute);
std::optional<Measure> read_width_attribute(pugi::xml_node);
bool read_on_off_attribute(pugi::xml_attribute);
bool read_on_off_attribute(pugi::xml_node);
bool read_line_attribute(pugi::xml_attribute);
bool read_line_attribute(pugi::xml_node);
std::optional<std::string> read_shadow_attribute(pugi::xml_attribute);
Expand Down
24 changes: 20 additions & 4 deletions src/odr/internal/ooxml/text/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,17 @@ Partial styles overlay a `wStyle` reference with the element's direct props β€”
paragraphs additionally fold in the paragraph-mark run props (`w:pPr/w:rPr`).
The *element-tree* cascade is then computed live: `get_intermediate_style` walks
the element parent chain from docDefaults down, `.override()`-ing each partial.
Table styles are direct-only (no `w:tblStyle` reference resolution).
A table resolves its `w:tblStyle` the same way a paragraph resolves its
`w:pStyle`, and contributes the whole resolved style β€” a table style carries the
paragraph and text properties of everything inside the table, and the cascade is
what carries them down. Its conditional formats (`w:tblStylePr`) are ignored.

**Contextual spacing is decided per paragraph, not per style.**
`w:contextualSpacing` drops the spacing towards a neighbouring paragraph of the
same style, which is what keeps a list tight, so it cannot live in the resolved
style: `partial_paragraph_style` compares the `w:pStyle` of the adjacent `w:p`
siblings and zeroes the margin it applies to. `Style` carries the flag
separately from its `ResolvedStyle` so an inherited one is seen.

**Editing & save.** `is_editable` β†’ true. `text_set_content` tokenises the new
string and splices `w:t` (with `xml:space="preserve"` for spaces) / `w:tab` nodes
Expand Down Expand Up @@ -87,7 +97,13 @@ Style/element coverage is in [`README.md`](README.md). Foundational gaps:
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`).
4. **Style stubs**: `resolve_table_row_style_` and `resolve_graphic_style_` are
empty; table cell width is parsed but not applied; the `w:default="1"` style
flag is ignored.
4. **Style stubs**: `resolve_graphic_style_` is empty; table cell width is
parsed but not applied; the `w:default="1"` style flag is ignored. Paragraph
spacing reads `w:before`/`w:after`/`w:line` but not `w:beforeLines`/
`w:afterLines`, and drops the value an autospacing flag shadows rather than
computing what word would. `w:lineRule="atLeast"` lowers to the same fixed
`line-height` as `exact`, because css has no minimum: right where the value
exceeds the natural line, tight where a taller font would have grown it.
Expressing it needs a second field on the public `ParagraphStyle`, since the
renderer cannot tell the two rules apart from one `Measure`.
5. **Comments / annotations** not modelled.
13 changes: 11 additions & 2 deletions src/odr/internal/ooxml/text/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,21 @@ Roughly ordered by importance.
- [x] paragraph
- [x] alignment
- [x] indentation / left & right margins
- [ ] top / bottom margins, line height
- [x] top / bottom margins, line height (`w:spacing`, incl.
`w:contextualSpacing`)
- [ ] `w:beforeLines` / `w:afterLines`, autospacing (the value next to an
autospacing flag is dropped rather than computed)
- [ ] `w:lineRule="atLeast"` as a *minimum* β€” css has no minimum line
height, so the value is applied as the line height and a line whose font
is taller than it does not grow the way word grows it
- [x] tables
- [x] table width
- [x] cell vertical alignment, borders
- [x] row height (`w:trHeight`, as a minimum β€” `w:hRule="exact"` is not)
- [x] table style reference (`w:tblStyle`, cascading its paragraph and text
properties into the table)
- [ ] cell width (parsed but not applied)
- [ ] table row styles
- [ ] conditional table formatting (`w:tblStylePr`: banding, first row, …)
- [x] page layout (`w:sectPr`: size, orientation, margins)
- [ ] one layout per section; the first section's applies to the document
- [ ] graphic / drawing styles
Expand Down
3 changes: 3 additions & 0 deletions src/odr/internal/ooxml/text/ooxml_text_document.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,9 @@ class ElementAdapter final : public abstract::ElementAdapter,
if (element.type == ElementType::span) {
return m_document->style_registry().partial_text_style(element.node);
}
if (element.type == ElementType::table) {
return m_document->style_registry().partial_table_style(element.node);
}
return {};
}

Expand Down
161 changes: 149 additions & 12 deletions src/odr/internal/ooxml/text/ooxml_text_style.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

#include <odr/internal/ooxml/ooxml_util.hpp>

#include <cstring>
#include <ranges>
#include <utility>
#include <vector>
Expand Down Expand Up @@ -75,6 +76,34 @@ void resolve_paragraph_style_(const pugi::xml_node node,
paragraph_properties.child("w:ind").attribute("w:end"))) {
result.margin.right = margin_right;
}

const pugi::xml_node spacing = paragraph_properties.child("w:spacing");

// an autospacing flag makes word compute the spacing itself and ignore the
// value next to it
if (!read_on_off_attribute(spacing.attribute("w:beforeAutospacing"))) {
if (const std::optional<Measure> margin_top =
read_twips_attribute(spacing.attribute("w:before"))) {
result.margin.top = margin_top;
}
}
if (!read_on_off_attribute(spacing.attribute("w:afterAutospacing"))) {
if (const std::optional<Measure> margin_bottom =
read_twips_attribute(spacing.attribute("w:after"))) {
result.margin.bottom = margin_bottom;
}
}
if (const pugi::xml_attribute line = spacing.attribute("w:line")) {
// [ECMA-376] 17.3.1.33: `atLeast`/`exact` measure in twips, the default
// `auto` in 240ths of a line
const char *line_rule = spacing.attribute("w:lineRule").value();
if (std::strcmp("atLeast", line_rule) == 0 ||
std::strcmp("exact", line_rule) == 0) {
result.line_height = read_twips_attribute(line);
Comment thread
andiwand marked this conversation as resolved.
} else {
result.line_height = Measure(line.as_double() / 2.4, DynamicUnit("%"));
}
}
}

void resolve_table_style_(const pugi::xml_node node, TableStyle &result) {
Expand All @@ -86,10 +115,19 @@ void resolve_table_style_(const pugi::xml_node node, TableStyle &result) {
}
}

void resolve_table_row_style_(pugi::xml_node /*node*/,
TableRowStyle & /*result*/) {
// TODO
// auto table_row_properties = node.child("w:trPr");
void resolve_table_row_style_(const pugi::xml_node node,
TableRowStyle &result) {
const pugi::xml_node table_row_properties = node.child("w:trPr");

// `auto` makes the row grow with its content, which is what html does anyway;
// word omits the rule for `atLeast`, so a bare `w:val` is a minimum height
const pugi::xml_node height = table_row_properties.child("w:trHeight");
if (std::strcmp("auto", height.attribute("w:hRule").value()) != 0) {
if (const std::optional<Measure> height_value =
read_twips_attribute(height.attribute("w:val"))) {
result.height = height_value;
}
}
}

void resolve_table_cell_style_(const pugi::xml_node node,
Expand Down Expand Up @@ -127,6 +165,68 @@ void resolve_graphic_style_(pugi::xml_node, GraphicStyle &) {
// TODO
}

/// A `w:sdt` and its `w:sdtContent` become a group, which the html renderer
/// writes as nothing but its children β€” the paragraphs around one are
/// neighbours on the page.
bool is_transparent_wrapper(const pugi::xml_node node) {
const char *name = node.name();
return std::strcmp("w:sdt", name) == 0 ||
std::strcmp("w:sdtContent", name) == 0;
}

bool is_block(const pugi::xml_node node) {
const char *name = node.name();
return std::strcmp("w:p", name) == 0 || std::strcmp("w:tbl", name) == 0;
}

/// The block `node` puts on the given side, `node` itself unless it wraps one;
/// nothing for a marker element such as `w:bookmarkEnd`.
pugi::xml_node block_within(const pugi::xml_node node, const bool previous) {
if (!is_transparent_wrapper(node)) {
return is_block(node) ? node : pugi::xml_node();
}
for (pugi::xml_node child = previous ? node.last_child() : node.first_child();
child;
child = previous ? child.previous_sibling() : child.next_sibling()) {
if (const pugi::xml_node block = block_within(child, previous)) {
return block;
}
}
return {};
}

/// The block that neighbours `node` in document order, stepping over marker
/// elements and seeing through the wrappers. Stops at anything else β€” a cell
/// or the body end β€” so a paragraph never neighbours one outside its container.
pugi::xml_node block_neighbour(pugi::xml_node node, const bool previous) {
while (true) {
for (pugi::xml_node sibling = previous ? node.previous_sibling()
: node.next_sibling();
sibling; sibling = previous ? sibling.previous_sibling()
: sibling.next_sibling()) {
if (const pugi::xml_node block = block_within(sibling, previous)) {
return block;
}
}
if (!is_transparent_wrapper(node.parent())) {
return {};
}
node = node.parent();
}
}

/// Whether `node` is a paragraph carrying the paragraph style `style_name`
/// names, an absent name matching an absent `w:pStyle`.
bool has_paragraph_style(const pugi::xml_node node,
const pugi::xml_attribute style_name) {
if (std::strcmp("w:p", node.name()) != 0) {
return false;
}
return std::strcmp(
node.child("w:pPr").child("w:pStyle").attribute("w:val").value(),
style_name.value()) == 0;
}

} // namespace

Style::Style(const pugi::xml_node node) : m_node{node} {
Expand All @@ -140,6 +240,7 @@ Style::Style(std::string name, const pugi::xml_node node, const Style *parent)
: m_name{std::move(name)}, m_node{node}, m_parent{parent} {
if (parent != nullptr) {
m_resolved = parent->m_resolved;
m_contextual_spacing = parent->m_contextual_spacing;
}

resolve_style_();
Expand All @@ -151,7 +252,14 @@ const Style *Style::parent() const { return m_parent; }

const ResolvedStyle &Style::resolved() const { return m_resolved; }

bool Style::contextual_spacing() const { return m_contextual_spacing; }

void Style::resolve_style_() {
if (const pugi::xml_node contextual_spacing =
m_node.child("w:pPr").child("w:contextualSpacing")) {
m_contextual_spacing = read_on_off_attribute(contextual_spacing);
}

resolve_text_style_(m_node, m_resolved.text_style);
resolve_paragraph_style_(m_node, m_resolved.paragraph_style);
resolve_table_style_(m_node, m_resolved.table_style);
Expand Down Expand Up @@ -204,29 +312,58 @@ StyleRegistry::partial_text_style(const pugi::xml_node node) const {

ResolvedStyle
StyleRegistry::partial_paragraph_style(const pugi::xml_node node) const {
const pugi::xml_node paragraph_properties = node.child("w:pPr");
const pugi::xml_attribute style_name =
paragraph_properties.child("w:pStyle").attribute("w:val");

ResolvedStyle result;
// TODO consider w:default="1"
if (const pugi::xml_attribute style_name =
node.child("w:pPr").child("w:pStyle").attribute("w:val")) {
if (const Style *style = this->style(style_name.value())) {
result = style->resolved();
}
const Style *style = style_name ? this->style(style_name.value()) : nullptr;
if (style != nullptr) {
result = style->resolved();
}
resolve_paragraph_style_(node, result.paragraph_style);
result.override(partial_text_style(node.child("w:pPr")));
result.override(partial_text_style(paragraph_properties));

bool contextual_spacing = style != nullptr && style->contextual_spacing();
if (const pugi::xml_node contextual_spacing_node =
paragraph_properties.child("w:contextualSpacing")) {
contextual_spacing = read_on_off_attribute(contextual_spacing_node);
}
if (contextual_spacing) {
// [ECMA-376] 17.3.1.9: the spacing towards a neighbouring paragraph of the
// same style is dropped, which is what keeps a list tight
const Measure none(0, DynamicUnit("in"));
if (has_paragraph_style(block_neighbour(node, true), style_name)) {
result.paragraph_style.margin.top = none;
}
if (has_paragraph_style(block_neighbour(node, false), style_name)) {
result.paragraph_style.margin.bottom = none;
}
}
return result;
}

ResolvedStyle
StyleRegistry::partial_table_style(const pugi::xml_node node) const {
ResolvedStyle result;
// a table style also carries the paragraph and text properties of everything
// in the table, which the element tree cascades down from here
if (const pugi::xml_attribute style_name =
node.child("w:tblPr").child("w:tblStyle").attribute("w:val")) {
if (const Style *style = this->style(style_name.value())) {
result = style->resolved();
}
}
resolve_table_style_(node, result.table_style);
return result;
}

ResolvedStyle
StyleRegistry::partial_table_row_style(const pugi::xml_node) const {
return {};
StyleRegistry::partial_table_row_style(const pugi::xml_node node) const {
ResolvedStyle result;
resolve_table_row_style_(node, result.table_row_style);
return result;
}

ResolvedStyle
Expand Down
4 changes: 4 additions & 0 deletions src/odr/internal/ooxml/text/ooxml_text_style.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,17 @@ class Style final {
[[nodiscard]] const Style *parent() const;

[[nodiscard]] const ResolvedStyle &resolved() const;
/// `w:contextualSpacing`, which is applied per paragraph and so is not part
/// of the resolved style.
[[nodiscard]] bool contextual_spacing() const;

private:
std::string m_name;
pugi::xml_node m_node;
const Style *m_parent{nullptr};

ResolvedStyle m_resolved;
bool m_contextual_spacing{false};

void resolve_style_();
void resolve_default_style_();
Expand Down
4 changes: 2 additions & 2 deletions test/data.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ odr_test_data(
odr_test_data(
PATH "reference-output/odr-public"
URL "https://github.com/opendocument-app/OpenDocument.test.output.git"
REVISION "4c90050f653012ac27a1c274dfad95c05b33624f")
REVISION "c667f1d619e31909ec81effe9148c79283291c01")

odr_test_data(
PATH "reference-output/odr-private"
URL "https://github.com/opendocument-app/OpenDocument.test-private.output.git"
REVISION "38975babdef0e35bc8fcb7d6f5c50d018740e7b7")
REVISION "fabb5651c6406570b735cf6acd1213a14f5b390c")
Loading
Loading