Skip to content

Commit 44cc7c7

Browse files
andiwandclaude
andcommitted
feat(html): let a host put a floor under the margin a view keeps
`HtmlConfig::min_content_margin` states, per side, the least distance the generated content keeps from the view's border. Every view already insets its content by some amount it names itself — 3mm for a reflowed text document, 16px for the page column and the source views, 8px for a font specimen, nothing for an image or a file listing — and no single number could stand in for all of them, so the config is a floor rather than a value: an unset side leaves the view exactly where it was, a set one only ever raises it. The arithmetic is css's. Each inset becomes `max(own, var(--odr-min-margin-*, 0px))`, so the view's own measure stays in the stylesheet and no unit has to be converted to state it. `write_content_margin_style` declares the variables per view and writes nothing at all when no side is set, which is what keeps the shipped stylesheets independent of the config — they are shared under one name when the config links rather than embeds them. One place still needs the number: the gutter around the page column is part of the width the view is fitted to, so `page_column_gutter_pixels` reads the config and a side css can read but `css_pixels` cannot convert keeps the built-in gutter — the margin still applies, only the fit cannot count it. A unit css could not read at all is dropped rather than written into the style element it would otherwise close. A sheet and the media view are not inset: the one meets the edge by design, the other is full-bleed. Bound in python, jni, wasm (`minContentMargin`, css lengths as strings) and apple, where `ODRMeasure` and `ODRDirectionalMeasure` gain the initialisers a caller needs to state one. Rendering with the default config is unchanged: every reference document is byte-identical, and the stylesheets that did change render pixel-identically under `compare-html`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoWywNazPio5vphzB59U5g
1 parent c2f2852 commit 44cc7c7

27 files changed

Lines changed: 478 additions & 39 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ The release run heads these entries with the version and opens a fresh
1616

1717
## Unreleased
1818

19+
- `HtmlConfig::min_content_margin` puts a floor under the distance the
20+
generated content keeps from the view's border, per side. Unset, every view
21+
keeps the inset it has today; a set side raises it - in a reflowed text
22+
document, the page column of a text, presentation, drawing or pdf view, the
23+
text and xml source views, the file listing, the image view and the font
24+
specimen. A sheet and the media view are not inset. Bound in python, jni,
25+
wasm (`minContentMargin`, css lengths as strings) and apple.
1926
- **Breaking** Bytes that do not read as text no longer come back as
2027
`text_file` and render as nonsense - `decode` throws `UnknownFileType` and
2128
`list_file_types` comes back empty. A file is text when it is empty, or its

‎apple/include/OdrCoreObjC/ODRHtml.h‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#import <Foundation/Foundation.h>
22

33
#import <OdrCoreObjC/ODRFilesystem.h>
4+
#import <OdrCoreObjC/ODRStyle.h>
45
#import <OdrCoreObjC/ODRTable.h>
56

67
NS_ASSUME_NONNULL_BEGIN
@@ -101,6 +102,11 @@ NS_SWIFT_NAME(HtmlConfig)
101102
/// The zoom the view opens at, 1 being actual size; `nil` follows the fit.
102103
@property(nonatomic, strong, nullable) NSNumber *initialZoom;
103104

105+
/// The least distance the generated content keeps from the view's border. A
106+
/// `nil` side keeps the inset the view already has; a set side raises it,
107+
/// never lowers it.
108+
@property(nonatomic, strong) ODRDirectionalMeasure *minContentMargin;
109+
104110
@property(nonatomic) BOOL formatHtml;
105111
/// Repeated `htmlIndentString` per nesting level; 0 disables indentation.
106112
@property(nonatomic) uint8_t htmlIndent;

‎apple/include/OdrCoreObjC/ODRStyle.h‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ NS_SWIFT_NAME(Measure)
7979
/// Magnitude and unit as odrcore writes them, e.g. `12pt`.
8080
@property(nonatomic, readonly, copy) NSString *stringValue;
8181

82+
/// A css length as odrcore writes one, e.g. `3mm`. A magnitude with no unit
83+
/// reads as unitless.
84+
- (instancetype)initWithString:(NSString *)string;
85+
- (instancetype)initWithMagnitude:(double)magnitude unit:(NSString *)unit;
86+
8287
- (instancetype)init NS_UNAVAILABLE;
8388
+ (instancetype)new NS_UNAVAILABLE;
8489
@end
@@ -92,6 +97,13 @@ NS_SWIFT_NAME(DirectionalMeasure)
9297
@property(nonatomic, readonly, nullable) ODRMeasure *left;
9398
@property(nonatomic, readonly, nullable) ODRMeasure *bottom;
9499

100+
/// For the sides a caller states itself, e.g.
101+
/// @ref ODRHtmlConfig.minContentMargin.
102+
- (instancetype)initWithRight:(nullable ODRMeasure *)right
103+
top:(nullable ODRMeasure *)top
104+
left:(nullable ODRMeasure *)left
105+
bottom:(nullable ODRMeasure *)bottom;
106+
95107
- (instancetype)init NS_UNAVAILABLE;
96108
+ (instancetype)new NS_UNAVAILABLE;
97109
@end

‎apple/src/ODRHtml.mm‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ - (instancetype)initWithNativeConfig:(const odr::HtmlConfig &)config {
108108
_viewportContent = config.viewport_content.has_value()
109109
? to_nsstring(*config.viewport_content)
110110
: nil;
111+
_minContentMargin =
112+
[ODRDirectionalMeasure directionalWithHandle:config.min_content_margin];
111113
_viewportWidth = config.viewport_width.has_value()
112114
? @(static_cast<unsigned int>(*config.viewport_width))
113115
: nil;
@@ -187,6 +189,11 @@ - (instancetype)initWithNativeConfig:(const odr::HtmlConfig &)config {
187189
} else {
188190
config.initial_zoom.reset();
189191
}
192+
if (_minContentMargin != nil) {
193+
config.min_content_margin = _minContentMargin.handle;
194+
} else {
195+
config.min_content_margin = odr::DirectionalStyle<odr::Measure>();
196+
}
190197
config.format_html = _formatHtml == YES;
191198
config.html_indent = _htmlIndent;
192199
config.html_indent_string = to_string(_htmlIndentString);

‎apple/src/ODRPrivate.h‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
#include <odr/logger.hpp>
1818
#include <odr/style.hpp>
1919

20+
#include <optional>
21+
2022
/// Cross-translation-unit access to the C++ value each wrapper owns.
2123
///
2224
/// Each `@implementation` holds its handle as an ivar, destroyed by ARC's
@@ -85,11 +87,13 @@ NS_ASSUME_NONNULL_BEGIN
8587

8688
@interface ODRMeasure (Private)
8789
+ (instancetype)measureWithHandle:(const odr::Measure &)handle;
90+
- (const std::optional<odr::Measure> &)handle;
8891
@end
8992

9093
@interface ODRDirectionalMeasure (Private)
9194
+ (instancetype)directionalWithHandle:
9295
(const odr::DirectionalStyle<odr::Measure> &)handle;
96+
- (odr::DirectionalStyle<odr::Measure>)handle;
9397
@end
9498

9599
@interface ODRDirectionalString (Private)

‎apple/src/ODRStyle.mm‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
using odr::apple::guarded_value;
1111
using odr::apple::to_nsstring;
12+
using odr::apple::to_string;
1213

1314
ODR_SAME_ENUM(ODRFontWeightNormal, odr::FontWeight::normal);
1415
ODR_SAME_ENUM(ODRFontWeightBold, odr::FontWeight::bold);
@@ -107,6 +108,26 @@ - (NSString *)stringValue {
107108
return guarded_value([&] { return to_nsstring(_handle->to_string()); }, @"");
108109
}
109110

111+
- (instancetype)initWithString:(NSString *)string {
112+
if ((self = [super init]) == nil) {
113+
return nil;
114+
}
115+
_handle = odr::Measure(to_string(string));
116+
return self;
117+
}
118+
119+
- (instancetype)initWithMagnitude:(double)magnitude unit:(NSString *)unit {
120+
if ((self = [super init]) == nil) {
121+
return nil;
122+
}
123+
_handle = odr::Measure(magnitude, odr::DynamicUnit(to_string(unit)));
124+
return self;
125+
}
126+
127+
- (const std::optional<odr::Measure> &)handle {
128+
return _handle;
129+
}
130+
110131
- (NSString *)description {
111132
return self.stringValue;
112133
}
@@ -127,6 +148,29 @@ + (instancetype)directionalWithHandle:
127148
return result;
128149
}
129150

151+
- (instancetype)initWithRight:(ODRMeasure *)right
152+
top:(ODRMeasure *)top
153+
left:(ODRMeasure *)left
154+
bottom:(ODRMeasure *)bottom {
155+
if ((self = [super init]) == nil) {
156+
return nil;
157+
}
158+
_right = right;
159+
_top = top;
160+
_left = left;
161+
_bottom = bottom;
162+
return self;
163+
}
164+
165+
- (odr::DirectionalStyle<odr::Measure>)handle {
166+
odr::DirectionalStyle<odr::Measure> result;
167+
result.right = _right != nil ? _right.handle : std::nullopt;
168+
result.top = _top != nil ? _top.handle : std::nullopt;
169+
result.left = _left != nil ? _left.handle : std::nullopt;
170+
result.bottom = _bottom != nil ? _bottom.handle : std::nullopt;
171+
return result;
172+
}
173+
130174
@end
131175

132176
@implementation ODRDirectionalString

‎apple/tests/OdrCoreTests.swift‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,26 @@ final class HtmlTests: XCTestCase {
119119
XCTAssertTrue(html.contains("<style"), "the html has no stylesheet")
120120
}
121121

122+
/// The C++ suite covers where the floor lands; this only proves the margin
123+
/// crosses the binding, `nil` sides and all.
124+
func testMinContentMarginReachesTheHtml() throws {
125+
let config = HtmlConfig()
126+
config.minContentMargin = DirectionalMeasure(
127+
right: nil, top: Measure(string: "12px"), left: Measure(string: "1cm"),
128+
bottom: nil)
129+
130+
let file = try DecodedFile.decode(path: try Fixture.odt())
131+
let service = try HtmlTranslator.translate(
132+
file: file, cachePath: try temporaryDirectory(), config: config)
133+
var resources: NSArray?
134+
let html = try XCTUnwrap(service.views.first).writeHtml(resources: &resources)
135+
136+
XCTAssertTrue(
137+
html.contains(":root{--odr-min-margin-top:12px;--odr-min-margin-left:1cm;}"),
138+
"the margin did not reach the html")
139+
XCTAssertFalse(html.contains("--odr-min-margin-right:"), "an unset side was written")
140+
}
141+
122142
/// A view's impl points into its service without owning it, so the view has
123143
/// to keep the service alive itself — the analogue of
124144
/// `ElementTreeTests.testElementsKeepTheirDocumentAlive`. Rendering off a

‎jni/java/app/opendocument/core/HtmlConfig.java‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ public final class HtmlConfig {
4242
/** The zoom the view opens at, 1 being actual size; {@code null} follows the fit. */
4343
public Double initialZoom;
4444

45+
/**
46+
* The least distance the generated content keeps from the view's border. A {@code null} side
47+
* keeps the inset the view already has; a set side raises it, never lowers it.
48+
*/
49+
public DirectionalMeasure minContentMargin =
50+
new DirectionalMeasure(null, null, null, null);
51+
4552
public boolean formatHtml = false;
4653
public int htmlIndent = 1;
4754
public String htmlIndentString = "\t";

‎jni/src/jni_style.cpp‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,44 @@ make_directional_measure(JNIEnv *env,
173173
make_measure(env, style.left), make_measure(env, style.bottom));
174174
}
175175

176+
std::optional<odr::Measure> measure_from_java(JNIEnv *env, jobject value) {
177+
if (value == nullptr) {
178+
return std::nullopt;
179+
}
180+
jclass cls = env->GetObjectClass(value);
181+
const double magnitude =
182+
env->GetDoubleField(value, env->GetFieldID(cls, "magnitude", "D"));
183+
auto unit = static_cast<jstring>(env->GetObjectField(
184+
value, env->GetFieldID(cls, "unit", "Ljava/lang/String;")));
185+
const odr::Measure result(magnitude, odr::DynamicUnit(to_string(env, unit)));
186+
env->DeleteLocalRef(unit);
187+
env->DeleteLocalRef(cls);
188+
return result;
189+
}
190+
191+
odr::DirectionalStyle<odr::Measure>
192+
directional_measure_from_java(JNIEnv *env, jobject value) {
193+
odr::DirectionalStyle<odr::Measure> result;
194+
if (value == nullptr) {
195+
return result;
196+
}
197+
198+
jclass cls = env->GetObjectClass(value);
199+
const auto side = [&](const char *name) {
200+
jobject measure = env->GetObjectField(
201+
value, env->GetFieldID(cls, name, "Lapp/opendocument/core/Measure;"));
202+
std::optional<odr::Measure> parsed = measure_from_java(env, measure);
203+
env->DeleteLocalRef(measure);
204+
return parsed;
205+
};
206+
result.right = side("right");
207+
result.top = side("top");
208+
result.left = side("left");
209+
result.bottom = side("bottom");
210+
env->DeleteLocalRef(cls);
211+
return result;
212+
}
213+
176214
jobject
177215
make_directional_string(JNIEnv *env,
178216
const odr::DirectionalStyle<std::string> &style) {
@@ -374,6 +412,8 @@ jobject html_config_to_java(JNIEnv *env, const odr::HtmlConfig &config) {
374412
box_integer(env, config.viewport_width));
375413
set_object("initialZoom", "Ljava/lang/Double;",
376414
box_double(env, config.initial_zoom));
415+
set_object("minContentMargin", "Lapp/opendocument/core/DirectionalMeasure;",
416+
make_directional_measure(env, config.min_content_margin));
377417
set_boolean("formatHtml", config.format_html);
378418
set_int("htmlIndent", config.html_indent);
379419
set_string("htmlIndentString", config.html_indent_string);
@@ -540,6 +580,12 @@ odr::HtmlConfig html_config_from_java(JNIEnv *env, jobject config) {
540580
}
541581
env->DeleteLocalRef(zoom);
542582
}
583+
{
584+
jobject margin = get_object("minContentMargin",
585+
"Lapp/opendocument/core/DirectionalMeasure;");
586+
result.min_content_margin = directional_measure_from_java(env, margin);
587+
env->DeleteLocalRef(margin);
588+
}
543589
result.format_html = get_boolean("formatHtml");
544590
result.html_indent = static_cast<std::uint8_t>(get_int("htmlIndent"));
545591
result.html_indent_string = get_string("htmlIndentString");

‎jni/tests/app/opendocument/core/HtmlTest.java‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,26 @@ void viewportConfigRoundTrips() throws IOException {
6262
assertEquals(Double.valueOf(1.5), readBack.initialZoom);
6363
}
6464

65+
/** The C++ suite covers where the floor lands; this only proves it crosses JNI. */
66+
@Test
67+
void minContentMarginReachesTheHtml() throws IOException {
68+
assertNull(new HtmlConfig().minContentMargin.top);
69+
assertTrue(!renderOdt(new HtmlConfig()).contains(":root{--odr-min-margin"));
70+
71+
HtmlConfig config = new HtmlConfig();
72+
config.minContentMargin =
73+
new DirectionalMeasure(null, new Measure(12, "px"), new Measure(1, "cm"), null);
74+
assertTrue(
75+
renderOdt(config).contains(":root{--odr-min-margin-top:12px;--odr-min-margin-left:1cm;}"));
76+
77+
Path cache = Files.createDirectories(tempDir.resolve("margin"));
78+
DecodedFile file = Odr.open(TestFiles.odtFile(tempDir).toString());
79+
HtmlConfig readBack = Html.translate(file, cache.toString(), config).config();
80+
assertEquals(new Measure(12, "px"), readBack.minContentMargin.top);
81+
assertEquals(new Measure(1, "cm"), readBack.minContentMargin.left);
82+
assertNull(readBack.minContentMargin.right);
83+
}
84+
6585
/** The C++ suite covers the mode matrix; this only proves the config crosses JNI. */
6686
@Test
6787
void viewportModeReachesTheHtml() throws IOException {

0 commit comments

Comments
 (0)