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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ The release run heads these entries with the version and opens a fresh
Validation no longer allocates a discarded JSON value tree.
- Refuse spreadsheet row and column edits when an affected formula cannot be
parsed, before changing cells or references.
- A pdf, a slide, a drawing page and a text page with margins print one page
to a sheet, at the size of the page and without the grey viewer background.
A dark render prints in light colors.

## v7.5.0 - 2026-10-06

Expand Down
2 changes: 1 addition & 1 deletion jni/tests/app/opendocument/core/HtmlTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ void colorSchemeReachesTheHtml() throws IOException {

HtmlConfig system = new HtmlConfig();
system.colorScheme = HtmlColorScheme.SYSTEM;
assertTrue(renderOdt(system).contains("media=\"(prefers-color-scheme: dark)\""));
assertTrue(renderOdt(system).contains("media=\"screen and (prefers-color-scheme: dark)\""));

DecodedFile file = Odr.open(TestFiles.odtFile(tempDir).toString());
HtmlConfig readBack = Html.translate(file, system).config();
Expand Down
46 changes: 46 additions & 0 deletions src/odr/internal/html/document.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,49 @@ viewport_mode_override(const Document &document, const HtmlConfig &config) {
: std::nullopt;
}

/// The sheet a page box prints on, sized by the first page. A text page puts
/// its margins on the sheet instead, so text that runs past a sheet keeps them.
void write_print_page_style(HtmlWriter &out, const Document &document) {
const Element root = document.root_element();
const bool text = document.document_type() == DocumentType::text;

PageLayout page_layout;
if (text) {
page_layout = root.as_text_root().page_layout();
} else if (const Element first = root.first_child(); !first) {
return;
} else if (document.document_type() == DocumentType::presentation) {
page_layout = first.as_slide().page_layout();
} else {
page_layout = first.as_page().page_layout();
}

out.write_header_style_begin();
out.out() << "@page{";
if (page_layout.width.has_value() && page_layout.height.has_value()) {
out.out() << "size:" << css_length(*page_layout.width) << " "
<< css_length(*page_layout.height) << ";";
}
if (!text) {
out.out() << "margin:0}";
out.write_header_style_end();
return;
}
const auto write_margin = [&](const std::string_view side,
const std::optional<Measure> &margin) {
if (margin.has_value()) {
out.out() << "margin-" << side << ":" << css_length(*margin) << ";";
}
};
write_margin("top", page_layout.margin.top);
write_margin("right", page_layout.margin.right);
write_margin("bottom", page_layout.margin.bottom);
write_margin("left", page_layout.margin.left);
out.out() << "}@media print{.odr-page-outer{width:auto!important;"
"min-height:0!important}.odr-page-inner{margin:0!important}}";
out.write_header_style_end();
}

/// @p name titles the view; empty for the file that holds every view.
void write_head(const Document &document, const WritingState &state,
const std::string &name,
Expand Down Expand Up @@ -184,6 +227,9 @@ void write_head(const Document &document, const WritingState &state,

write_document_style(state);
write_document_dark_style(state);
if (paged_content) {
write_print_page_style(out, document);
}
write_search_style(state);
write_search_dark_style(state);
if (document.document_type() == DocumentType::spreadsheet) {
Expand Down
4 changes: 2 additions & 2 deletions src/odr/internal/html/frontend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,8 @@ bool html::writes_dark_style(const HtmlConfig &config) {

std::string_view html::dark_style_media(const HtmlConfig &config) {
return config.color_scheme == HtmlColorScheme::system
? "(prefers-color-scheme: dark)"
: "";
? "screen and (prefers-color-scheme: dark)"
: "screen";
}

void html::write_document_style(const WritingState &state) {
Expand Down
2 changes: 1 addition & 1 deletion src/odr/internal/html/frontend.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ void write_search_style(const WritingState &state);
void write_search_dark_style(const WritingState &state);

/// For a view writing its style inline: whether to write a dark one, and the
/// `media` gating it — empty for @ref odr::HtmlColorScheme::dark.
/// `media` gating it. A print stays light, so the query names `screen`.
bool writes_dark_style(const HtmlConfig &config);
std::string_view dark_style_media(const HtmlConfig &config);

Expand Down
7 changes: 7 additions & 0 deletions src/odr/internal/html/frontend/document.css
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,10 @@ x-s{display:inline;white-space-collapse:preserve;tab-size:.5in}
It hangs into the item's padding so wrapped lines align under the text. */
.odr-list-item{padding-left:2em}
.odr-list-marker{display:inline-block;min-width:2em;margin-left:-2em;white-space:pre}
/* One page box to a sheet. The `@page` rule the head writes sizes the sheet. */
@media print{
.odr-background{background:none}
.odr-pages{display:block;padding:0;width:auto;min-width:0}
.odr-page-outer{margin:0;box-shadow:none;break-after:page}
.odr-page-outer:last-child{break-after:auto}
}
34 changes: 28 additions & 6 deletions src/odr/internal/html/pdf_file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
#include <map>
#include <memory>
#include <mutex>
#include <set>
#include <span>
#include <sstream>
#include <string>
Expand Down Expand Up @@ -1856,8 +1857,7 @@ class HtmlServiceImpl final : public HtmlService {
}
substitute_faces.append_faces(font_faces);

const std::optional<double> content = content_pixels(pages_out, config());
write_header_common(state, font_faces, font_styles, styles, content, [&] {
write_header_common(state, font_faces, font_styles, styles, pages_out, [&] {
// Visual layer glyph spans: not selectable (selection rides the `.sel`
// layer).
out.out() << ".g{user-select:none}";
Expand Down Expand Up @@ -2349,8 +2349,7 @@ class HtmlServiceImpl final : public HtmlService {
substitute_faces.append_faces(font_faces);

// ---- Pass 2: write HTML ---------------------------------------------
const std::optional<double> content = content_pixels(pages_out, config());
write_header_common(state, font_faces, font_styles, styles, content, [&] {
write_header_common(state, font_faces, font_styles, styles, pages_out, [&] {
// Invisible text render modes (Tr 3/7).
out.out() << ".i{color:transparent}";
// Unclean glyphs via generated content, out of the DOM text stream.
Expand Down Expand Up @@ -2649,6 +2648,7 @@ class HtmlServiceImpl final : public HtmlService {
std::ostringstream h;
h << "height:" << height * pt_to_in << "in";
add_class(classes, "y", std::move(h).str());
add_class(classes, "q", "page:" + print_page_name(width, height));
}

// Onto the displayed box: 90° sends the top-left corner to the top-right,
Expand Down Expand Up @@ -2748,16 +2748,23 @@ class HtmlServiceImpl final : public HtmlService {
return widest * pt_to_in * 96.0 + page_column_gutter_pixels(config);
}

/// The named `@page` a page box prints on, one per distinct size.
static std::string print_page_name(const double width, const double height) {
return "odr-" + std::to_string(std::lround(width * 100)) + "x" +
std::to_string(std::lround(height * 100));
}

/// The document/head prologue shared by both modes, with `write_mode_css()`
/// slotted between the constant rules. Leaves the writer after `</head>`.
template <typename WriteModeCss>
template <typename PageOut, typename WriteModeCss>
void write_header_common(const WritingState &state,
const std::string &font_faces,
const std::string &font_styles,
const StyleRegistry &styles,
const std::optional<double> content,
const std::vector<PageOut> &pages,
WriteModeCss &&write_mode_css) const {
HtmlWriter &out = state.out();
const std::optional<double> content = content_pixels(pages, config());

out.write_begin();
out.write_header_begin();
Expand All @@ -2783,6 +2790,21 @@ class HtmlServiceImpl final : public HtmlService {
"margin:0 max(16px,var(--odr-min-margin-right,0px)) 0 "
"max(16px,var(--odr-min-margin-left,0px));background:#fff;"
"overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.5)}";
// One page to a sheet. WebKit prints an off-screen `content-visibility`
// page blank.
out.out() << "@media print{body{background:none}"
".d{display:block;padding:0;width:auto;min-width:0}"
".p{content-visibility:visible;margin:0;box-shadow:none;"
"break-after:page}.p:last-child{break-after:auto}}"
"@page{margin:0}";
std::set<std::string> print_pages;
for (const PageOut &page : pages) {
if (std::string name = print_page_name(page.width, page.height);
print_pages.insert(name).second) {
out.out() << "@page " << name << "{size:" << page.width * pt_to_in
<< "in " << page.height * pt_to_in << "in}";
}
}
// `.t`: shared base for all absolutely-positioned line blocks.
// `font-size:0` collapses its strut, which outranks the run it holds and
// would take the line box's baseline.
Expand Down
18 changes: 16 additions & 2 deletions test/src/html_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,18 @@ std::string viewport_of(const std::string &page) {

} // namespace

TEST(html, a_text_page_prints_with_its_margins_on_the_sheet) {
HtmlConfig config;
config.text_document_margin = true;
const std::string paged = render_odt(config);
EXPECT_NE(paged.find("@page{size:"), std::string::npos);
EXPECT_NE(paged.find(".odr-page-inner{margin:0!important}"),
std::string::npos);

config.text_document_margin = false;
EXPECT_EQ(render_odt(config).find("@page{"), std::string::npos);
}

// A browser floors the page scale at 0.25, so a page more than four screens
// wide cannot be zoomed out to. A wider page states a floor of its own.
TEST(html, fitting_the_width_states_a_floor_a_wide_page_needs) {
Expand Down Expand Up @@ -540,11 +552,13 @@ TEST(html, color_scheme_writes_the_dark_style) {
const std::string dark = render_odt(config);
EXPECT_NE(dark.find("color-scheme:dark"), std::string::npos);
EXPECT_EQ(dark.find("prefers-color-scheme"), std::string::npos);
EXPECT_NE(dark.find(R"html(<style media="screen">)html"), std::string::npos);

config.color_scheme = HtmlColorScheme::system;
const std::string system = render_odt(config);
EXPECT_NE(
system.find(R"html(<style media="(prefers-color-scheme: dark)">)html"),
system.find(
R"html(<style media="screen and (prefers-color-scheme: dark)">)html"),
std::string::npos);
}

Expand All @@ -563,7 +577,7 @@ TEST(html, linked_dark_style_is_served) {

EXPECT_NE(
out.str().find(
R"html(<link rel="stylesheet" href="document-dark.css" media="(prefers-color-scheme: dark)"/>)html"),
R"html(<link rel="stylesheet" href="document-dark.css" media="screen and (prefers-color-scheme: dark)"/>)html"),
std::string::npos);
EXPECT_TRUE(std::ranges::any_of(resources, [](const auto &entry) {
return entry.second.has_value() && *entry.second == "document-dark.css";
Expand Down
20 changes: 20 additions & 0 deletions test/src/internal/pdf/pdf_file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,26 @@ TEST(PdfFile, uniform_run_at_the_floor_keeps_its_own_font_size) {
EXPECT_FALSE(contains(html, "transform:scale("));
}

TEST(PdfFile, each_page_prints_on_a_sheet_of_its_own_size) {
PdfFileBuilder builder;
builder.object("<< /Type /Catalog /Pages 2 0 R >>")
.object("<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 >>")
.object("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>")
.object("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 792 612] >>")
.object("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>");
const std::string bytes = builder.trailer("/Root 1 0 R").build_classic();

for (const PdfTextMode mode :
{PdfTextMode::dual_layer, PdfTextMode::single_layer}) {
const std::string html = render_html(bytes, mode);
EXPECT_EQ(count(html, "@page odr-61200x79200{size:8.5in 11in}"), 1);
EXPECT_EQ(count(html, "@page odr-79200x61200{size:11in 8.5in}"), 1);
EXPECT_EQ(count(html, "{page:odr-61200x79200}"), 1);
EXPECT_EQ(count(html, "{page:odr-79200x61200}"), 1);
EXPECT_TRUE(contains(html, "break-after:page"));
}
}

TEST(PdfFile, an_unencrypted_file_states_no_permissions) {
const odr::PdfFile file(open_pdf(info_mini_pdf()));
EXPECT_FALSE(file.has_permissions());
Expand Down
Loading