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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ The release run heads these entries with the version and opens a fresh

## Unreleased

- A `.docx` `wp:anchor` drawing floats at its offset with its wrap and side,
instead of sitting in the line. Closes #803.

- A `.docx` table's own borders (`w:tblPr/w:tblBorders`) are drawn, and no
longer four times too thick. New `TableStyle::border`,
`::border_inside_horizontal` and `::border_inside_vertical`, mirrored in the
JNI, Apple and Python bindings.

- A table cell in a text document starts its content at the top, as word and
odf do.

- A fitted or zoomed view scales its text with the page in WebKit, where the
type used to stay at its unscaled size. `HtmlViewportMode::fit_width_by_view`
is usable on iOS. Closes #761.
Expand Down
3 changes: 3 additions & 0 deletions apple/include/OdrCoreObjC/ODRStyle.h
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,9 @@ NS_SWIFT_NAME(ParagraphStyle)
NS_SWIFT_NAME(TableStyle)
@interface ODRTableStyle : NSObject
@property(nonatomic, readonly, nullable) ODRMeasure *width;
@property(nonatomic, readonly) ODRDirectionalString *border;
@property(nonatomic, readonly, nullable, copy) NSString *borderInsideHorizontal;
@property(nonatomic, readonly, nullable, copy) NSString *borderInsideVertical;

- (instancetype)init NS_UNAVAILABLE;
+ (instancetype)new NS_UNAVAILABLE;
Expand Down
3 changes: 3 additions & 0 deletions apple/src/ODRStyle.mm
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,9 @@ @implementation ODRTableStyle
+ (instancetype)styleWithHandle:(const odr::TableStyle &)handle {
ODRTableStyle *const result = [[ODRTableStyle alloc] init];
result->_width = box(handle.width);
result->_border = [ODRDirectionalString directionalWithHandle:handle.border];
result->_borderInsideHorizontal = box_string(handle.border_inside_horizontal);
result->_borderInsideVertical = box_string(handle.border_inside_vertical);
return result;
}

Expand Down
12 changes: 11 additions & 1 deletion jni/java/app/opendocument/core/TableStyle.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,18 @@
/** Style of a table. Mirrors {@code odr::TableStyle}; fields may be {@code null}. */
public final class TableStyle {
public final Measure width;
public final DirectionalString border;
public final String borderInsideHorizontal;
public final String borderInsideVertical;

TableStyle(Measure width) {
TableStyle(
Measure width,
DirectionalString border,
String borderInsideHorizontal,
String borderInsideVertical) {
this.width = width;
this.border = border;
this.borderInsideHorizontal = borderInsideHorizontal;
this.borderInsideVertical = borderInsideVertical;
}
}
9 changes: 7 additions & 2 deletions jni/src/jni_style.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -282,8 +282,13 @@ jobject make_paragraph_style(JNIEnv *env, const odr::ParagraphStyle &style) {

jobject make_table_style(JNIEnv *env, const odr::TableStyle &style) {
return new_object(env, "app/opendocument/core/TableStyle",
"(Lapp/opendocument/core/Measure;)V",
make_measure(env, style.width));
"(Lapp/opendocument/core/Measure;"
"Lapp/opendocument/core/DirectionalString;"
"Ljava/lang/String;Ljava/lang/String;)V",
make_measure(env, style.width),
make_directional_string(env, style.border),
make_string_opt(env, style.border_inside_horizontal),
make_string_opt(env, style.border_inside_vertical));
}

jobject make_table_column_style(JNIEnv *env,
Expand Down
7 changes: 6 additions & 1 deletion python/src/bind_style.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,12 @@ void odr_python::bind_style(py::module_ &m) {

py::class_<odr::TableStyle>(m, "TableStyle")
.def(py::init<>())
.def_readwrite("width", &odr::TableStyle::width);
.def_readwrite("width", &odr::TableStyle::width)
.def_readwrite("border", &odr::TableStyle::border)
.def_readwrite("border_inside_horizontal",
&odr::TableStyle::border_inside_horizontal)
.def_readwrite("border_inside_vertical",
&odr::TableStyle::border_inside_vertical);

py::class_<odr::TableColumnStyle>(m, "TableColumnStyle")
.def(py::init<>())
Expand Down
9 changes: 6 additions & 3 deletions src/odr/internal/html/document_style.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -513,15 +513,18 @@ std::string html::translate_frame_properties(const Frame &frame) {
result += "display:block;";
result += "float:right;clear:both;";
result += "shape-outside:content-box;";
if (const std::optional<Measure> x = frame.x(); x.has_value()) {
const std::optional<Measure> x = frame.x();
if (x.has_value()) {
result += "margin-left:" + x->to_string() + ";";
}
if (const std::optional<Measure> y = frame.y(); y.has_value()) {
result += "margin-top:" + y->to_string() + ";";
}
if (const std::optional<Measure> width = frame.width(); width.has_value()) {
// holds the frame at its offset; with no offset it would pin it left
if (const std::optional<Measure> width = frame.width();
width.has_value() && x.has_value()) {
result += "margin-right:calc(100% - ";
result += frame.x().value_or(Measure(0, DynamicUnit("in"))).to_string();
result += x->to_string();
result += " - ";
result += width->to_string();
result += ");";
Expand Down
3 changes: 3 additions & 0 deletions src/odr/internal/html/frontend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ body{margin:0;background:#fff}
/* What the formats anchor against: a page for shapes, a paragraph or a cell
for frames. */
x-p,td,.odr-page-outer{position:relative}
/* Word and odf start a cell at its top where the browser default centres it; a
sheet says its own, more specifically. */
td{vertical-align:top}
x-p{display:block}
x-s{display:inline}
.odr-background{padding:0;background:#525659}
Expand Down
38 changes: 31 additions & 7 deletions src/odr/internal/ooxml/ooxml_util.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ ooxml::read_half_point_attribute(const pugi::xml_attribute attribute) {
return Measure(attribute.as_double() * 0.5, DynamicUnit("pt"));
}

std::optional<Measure>
ooxml::read_eighth_point_attribute(const pugi::xml_attribute attribute) {
if (!attribute) {
return {};
}
return Measure(attribute.as_double() * 0.125, DynamicUnit("pt"));
}

std::optional<Measure>
ooxml::read_hundredth_point_attribute(const pugi::xml_attribute attribute) {
if (!attribute) {
Expand All @@ -101,6 +109,13 @@ ooxml::read_emus_attribute(const pugi::xml_attribute attribute) {
return Measure(attribute.as_double() / 914400.0, DynamicUnit("in"));
}

std::optional<Measure> ooxml::read_emus_text(const pugi::xml_node node) {
if (!node) {
return {};
}
return Measure(node.text().as_double() / 914400.0, DynamicUnit("in"));
}

std::optional<Measure>
ooxml::read_twips_attribute(const pugi::xml_attribute attribute) {
if (!attribute) {
Expand Down Expand Up @@ -308,24 +323,33 @@ std::optional<std::string> ooxml::read_border_node(const pugi::xml_node node) {
return {};
}
const char *val = node.attribute("w:val").value();
if (std::strcmp("nil", val) == 0) {
return {};
if (std::strcmp("nil", val) == 0 || std::strcmp("none", val) == 0) {
return "0 none";
}
const std::optional<Measure> size =
read_half_point_attribute(node.attribute("w:sz"));
read_eighth_point_attribute(node.attribute("w:sz"));
if (!size.has_value()) {
return {};
}
std::string result;
result.append(size->to_string()).append(" ");
result.append(std::strcmp("none", val) == 0 ? "none " : "solid ");
// `auto` reads as no color, which css then takes from the text
std::string result = size->to_string() + " solid";
if (const std::optional<Color> color =
read_color_attribute(node.attribute("w:color"))) {
result.append(html::color(*color));
result.append(" ").append(html::color(*color));
}
return result;
}

DirectionalStyle<std::string>
ooxml::read_borders_node(const pugi::xml_node node) {
DirectionalStyle<std::string> result;
result.right = read_border_node(node.child("w:right"));
result.top = read_border_node(node.child("w:top"));
result.left = read_border_node(node.child("w:left"));
result.bottom = read_border_node(node.child("w:bottom"));
return result;
}

std::unordered_map<std::string, std::string>
ooxml::parse_relationships(const pugi::xml_document &relations) {
std::unordered_map<std::string, std::string> result;
Expand Down
7 changes: 7 additions & 0 deletions src/odr/internal/ooxml/ooxml_util.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,11 @@ namespace odr::internal::ooxml {
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);
std::optional<Measure> read_eighth_point_attribute(pugi::xml_attribute);
std::optional<Measure> read_hundredth_point_attribute(pugi::xml_attribute);
std::optional<Measure> read_emus_attribute(pugi::xml_attribute);
/// EMUs written as a node's text, the way `wp:posOffset` states an offset.
std::optional<Measure> read_emus_text(pugi::xml_node);
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);
Expand All @@ -49,7 +52,11 @@ std::optional<TextAlign> read_drawing_text_align_attribute(pugi::xml_attribute);
std::optional<VerticalAlign> read_vertical_align_attribute(pugi::xml_attribute);
std::optional<VerticalAlign>
read_drawing_vertical_align_attribute(pugi::xml_attribute);
/// [ECMA-376] 17.3.4 `CT_Border`; `w:sz` is in eighths of a point. `nil` and
/// `none` draw nothing, which is not the same as saying nothing.
std::optional<std::string> read_border_node(pugi::xml_node);
/// The four sides of a `w:tblBorders`/`w:tcBorders`.
DirectionalStyle<std::string> read_borders_node(pugi::xml_node);

using Relations = std::unordered_map<std::string, std::string>;
using XmlDocumentsAndRelations =
Expand Down
18 changes: 16 additions & 2 deletions src/odr/internal/ooxml/text/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,20 @@ A table resolves its `w:tblStyle` the same way a paragraph resolves its
paragraph and text properties of everything inside the table, and the cascade is
what carries them down. Its conditional formats (`w:tblStylePr`) are ignored.

**A table's borders are lowered onto its cells**, because css cannot draw an
inside rule from the `<table>`. `table_cell_border` resolves each edge as the
cell's own `w:tcBorders`, then the neighbour's opposite edge, then the table's
`w:tblPr/w:tblBorders` — and returns only the edges the cell *leads*: its top
and left, plus the frame it closes on the last row and column. So a rule between
two cells is one line, whichever of them states it, and `nil` has to read as "no
border" rather than as silence. Word picks the heavier of two competing borders;
here the leading cell's own wins.

**A drawing anchored to the text stays in the text.** `wp:anchor` reports
`AnchorType::at_paragraph` whatever `relativeFrom` says, because css only makes
text flow around a box that is in the flow — hence also a dropped page-relative
offset, and `wrapText="bothSides"`/`"largest"` taking its side from `wp:align`.

**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
Expand Down Expand Up @@ -97,8 +111,8 @@ 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_graphic_style_` is empty; table cell width is
parsed but not applied; the `w:default="1"` style flag is ignored. Paragraph
4. **Style stubs**: 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
Expand Down
20 changes: 16 additions & 4 deletions src/odr/internal/ooxml/text/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,27 @@ Roughly ordered by importance.
is taller than it does not grow the way word grows it
- [x] tables
- [x] table width
- [x] cell vertical alignment, borders
- [x] cell vertical alignment
- [x] borders, on the cell (`w:tcBorders`) and on the table
(`w:tblPr/w:tblBorders`, frame + `w:insideH`/`w:insideV`, lowered onto the
cells)
- [ ] word's conflict resolution where two cells meet at a rule — the leading
cell's own border wins rather than the heavier of the two
- [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)
- [x] table style reference (`w:tblStyle`, cascading its paragraph, text and
border properties into the table)
- [ ] cell width (parsed but not applied)
- [ ] 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
- [x] graphic / drawing styles
- [x] floating drawings (`wp:anchor`): `wp:positionH`/`wp:positionV` offsets,
`wp:align` side, `wp:wrapSquare`/`Tight`/`Through`/`TopAndBottom`/`None`
- [x] `behindDoc`, as a negative z-index
- [ ] a page-relative origin, and `wp:positionV`'s `wp:align` — a frame is
kept in the text flow, so neither has an origin to measure against
- [ ] `relativeHeight` as the stacking order between two drawings
- [ ] fill and stroke

## References

Expand Down
54 changes: 38 additions & 16 deletions src/odr/internal/ooxml/text/ooxml_text_document.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -515,23 +515,33 @@ class ElementAdapter final : public abstract::ElementAdapter,
[[nodiscard]] TableCellStyle
table_cell_style(const ElementIdentifier element_id) const override {
const pugi::xml_node node = get_node(element_id);
return m_document->style_registry()
.partial_table_cell_style(node)
.table_cell_style;
const StyleRegistry &styles = m_document->style_registry();

TableCellStyle result =
styles.partial_table_cell_style(node).table_cell_style;
result.border = table_cell_border(
node, get_cell_above(node),
styles.partial_table_style(node.parent().parent()).table_style,
table_cell_span(element_id).rows);
return result;
}

[[nodiscard]] AnchorType frame_anchor_type(
[[maybe_unused]] const ElementIdentifier element_id) const override {
// TODO `wp:anchor` is floating, not as_char
return AnchorType::as_char;
[[nodiscard]] AnchorType
frame_anchor_type(const ElementIdentifier element_id) const override {
// [ECMA-376] 20.4.2.8. Whatever a `wp:anchor` says it is relative to, css
// flows text around a box only while that box is in the flow.
return get_node(element_id).child("wp:anchor") ? AnchorType::at_paragraph
: AnchorType::as_char;
}
[[nodiscard]] std::optional<Measure>
frame_x([[maybe_unused]] const ElementIdentifier element_id) const override {
return std::nullopt;
frame_x(const ElementIdentifier element_id) const override {
return read_frame_offset(
get_frame_inner_node(element_id).child("wp:positionH"));
}
[[nodiscard]] std::optional<Measure>
frame_y([[maybe_unused]] const ElementIdentifier element_id) const override {
return std::nullopt;
frame_y(const ElementIdentifier element_id) const override {
return read_frame_offset(
get_frame_inner_node(element_id).child("wp:positionV"));
}
[[nodiscard]] std::optional<Measure>
frame_width(const ElementIdentifier element_id) const override {
Expand All @@ -543,17 +553,22 @@ class ElementAdapter final : public abstract::ElementAdapter,
const pugi::xml_node inner_node = get_frame_inner_node(element_id);
return read_emus_attribute(inner_node.child("wp:extent").attribute("cy"));
}
[[nodiscard]] std::optional<std::int32_t> frame_z_index(
[[maybe_unused]] const ElementIdentifier element_id) const override {
[[nodiscard]] std::optional<std::int32_t>
frame_z_index(const ElementIdentifier element_id) const override {
// [ECMA-376] 20.4.2.3 `behindDoc`, the drawing word paints under the text.
if (read_on_off_attribute(
get_frame_inner_node(element_id).attribute("behindDoc"))) {
return -1;
}
return std::nullopt;
}
[[nodiscard]] std::optional<DrawingTransform> frame_transform(
[[maybe_unused]] const ElementIdentifier element_id) const override {
return std::nullopt;
}
[[nodiscard]] GraphicStyle frame_style(
[[maybe_unused]] const ElementIdentifier element_id) const override {
return {};
[[nodiscard]] GraphicStyle
frame_style(const ElementIdentifier element_id) const override {
return read_frame_style(get_frame_inner_node(element_id));
}

[[nodiscard]] bool
Expand Down Expand Up @@ -616,6 +631,13 @@ class ElementAdapter final : public abstract::ElementAdapter,
return !val || std::strcmp(val.value(), "continue") == 0;
}

/// The cell the previous row puts at this one's grid column, if any.
[[nodiscard]] static pugi::xml_node
get_cell_above(const pugi::xml_node cell_node) {
return get_cell_at_grid_column(cell_node.parent().previous_sibling("w:tr"),
get_grid_column(cell_node));
}

/// Grid column a `w:tc` starts at, i.e. the preceding cells' `w:gridSpan`s.
[[nodiscard]] static std::uint32_t
get_grid_column(const pugi::xml_node cell_node) {
Expand Down
Loading
Loading