Skip to content

Commit 4d77131

Browse files
committed
Make output coordinates consistent with standard coordinate systems (SVG coordinates)
1 parent dce5f7f commit 4d77131

7 files changed

Lines changed: 80 additions & 57 deletions

File tree

README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ column = TextColumn(
5252
)
5353

5454
# Get bounding boxes for rendering
55-
text, x, dx, y, dy = column.to_bounding_boxes()
55+
text, x, dx, x_origin, y, dy, y_origin = column.to_bounding_boxes()
5656
```
5757

5858
## Usage Examples
@@ -106,7 +106,7 @@ multi_column = MultiColumn(
106106
)
107107

108108
# Get bounding boxes with column information
109-
text, x, dx, y, dy, column_id = multi_column.to_bounding_boxes(
109+
text, x, dx, x_origin, y, dy, y_origin, column_id = multi_column.to_bounding_boxes(
110110
max_lines_per_column=20,
111111
line_spacing=1.2
112112
)
@@ -141,7 +141,7 @@ multi_column = MultiColumn(
141141
)
142142

143143
# Get positioned text for the layout
144-
text, x, dx, y, dy, page = layout.to_bounding_boxes(multi_column)
144+
text, x, dx, x_origin, y, dy, y_origin, page = layout.to_bounding_boxes(multi_column)
145145
```
146146

147147
### SVG Rendering
@@ -150,8 +150,8 @@ text, x, dx, y, dy, page = layout.to_bounding_boxes(multi_column)
150150
# Generate SVG output
151151
svg_content = font_measure.render_svg(
152152
text=text,
153-
x=x,
154-
y=y,
153+
x_origin=x_origin,
154+
y_origin=y_origin,
155155
fontsize=12,
156156
canvas_width=600,
157157
canvas_height=800
@@ -162,6 +162,8 @@ with open("output.svg", "w") as f:
162162
f.write(svg_content)
163163
```
164164

165+
**Note on Coordinate System**: TextShape uses the SVG/HTML coordinate system where the origin (0, 0) is at the top-left corner, and the y-axis increases downward. All coordinates returned by `to_bounding_boxes()` follow this convention.
166+
165167
### Line breaking with hyphenation
166168

167169
```python
@@ -260,7 +262,7 @@ column = TextColumn(fragments, column_width=widths, fontsize=12)
260262
Control spacing between lines:
261263

262264
```python
263-
text, x, dx, y, dy = column.to_bounding_boxes(line_spacing=1.5)
265+
text, x, dx, x_origin, y, dy, y_origin = column.to_bounding_boxes(line_spacing=1.5)
264266
```
265267

266268
## Performance Tips

tests/test_layout.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
from textshape.layout import Layout
99

1010

11-
def test_multi_column():
11+
def test_max_lines_per_column():
12+
"""Test the calculation of maximum lines per column based on page height and font size. We don't move "columns" here,
13+
but we will color what would have been a separate column. This also shows that 'newlines' should be ignored if they
14+
occur on column boundaries.
15+
"""
1216
text = '\t' + '\n\n\t'.join(TEXTS)
1317

1418
fontsize = 12
@@ -19,9 +23,9 @@ def test_multi_column():
1923
fragments = f(text)
2024
column = MultiColumn(fragments, column_width=width, fontsize=fontsize, justify=True)
2125

22-
text, x, dx, y, dy, c = column.to_bounding_boxes(max_lines_per_column=3, reset_y=False)
26+
text, x, dx, x_orig, y, dy, y_orig, c = column.to_bounding_boxes(max_lines_per_column=3, reset_y=False)
2327

24-
svg = fm.render_svg(text, x, y, fontsize=fontsize, canvas_width=width)
28+
svg = fm.render_svg(text, x_orig, y_orig, fontsize=fontsize, canvas_width=width)
2529

2630
boundaries = re.compile(r"\t|\b[^\s]")
2731
separators = np.array([x.span()[0] for x in boundaries.finditer(text)], dtype=int)
@@ -75,18 +79,26 @@ def test_layout():
7579
fragments = f(text)
7680
column = MultiColumn(fragments, column_width=layout.column_widths, fontsize=fontsize, justify=True)
7781

78-
text, x, dx, y, dy, p = layout.to_bounding_boxes(column)
79-
y = y - p_height * p # Adjust y to start from the top of the page
82+
text, _, dx, x_orig, _, dy, y_orig, p = layout.to_bounding_boxes(column)
83+
y_orig = y_orig + p_height * p # Adjust y to start from the top of the page
8084

8185
# Only keep values for the first page
82-
svg = fm.render_svg(text, x, y, fontsize=fontsize, canvas_width=p_width, canvas_height=(p[-1]+1) * p_height)
86+
svg = fm.render_svg(text, x_orig, y_orig, fontsize=fontsize, canvas_width=p_width, canvas_height=(p[-1]+1) * p_height)
8387

8488
def draw_rect(x, y, dx, dy, color="red"):
8589
return f'<rect width="{dx:.2f}" height="{dy:.2f}" x="{x:.2f}" y="{y:.2f}" style="stroke:{color}"/>'
8690

91+
# Draw a boundary line between pages
8792
rects = [
88-
draw_rect(margin, -z*p_height, p_width - 2*margin, 1, "black") for z in range(1,p.max() + 1)
93+
draw_rect(margin, z*p_height, p_width - 2*margin, 1, "black") for z in range(1,p.max() + 1)
8994
]
95+
96+
# Draw a boundary line for the page margins
97+
rects += [
98+
draw_rect(margin, p_height * p + margin, p_width - 2*margin, p_height - 2*margin, "blue")
99+
for p in range(0, p[-1] + 1)
100+
]
101+
90102
rects = "\n".join(rects)
91103
rects = f'<g style="stroke-width:1;" fill-opacity="0">\n{rects}\n</g>\n</svg>'
92104

tests/test_textwrap.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,9 @@ def test_oneliner():
145145
f = TextFragmenter(measure=fm, splitter=dummy_splitter)
146146
fragments = f(text)
147147
column = TextColumn(fragments, column_width=width, fontsize=fontsize)
148-
text, x, dx, y, dy = column.to_bounding_boxes()
148+
text, _, dx, x_origin, _, dy, y_origin = column.to_bounding_boxes()
149149

150-
svg = fm.render_svg(text, x, y, fontsize=fontsize, canvas_width=width)
150+
svg = fm.render_svg(text, x_origin, y_origin, fontsize=fontsize, canvas_width=width)
151151
with open(DIR / "text-oneliner.svg", "w") as f:
152152
f.write(svg)
153153

@@ -162,8 +162,8 @@ def test_wrap_font():
162162
fragments = f(text)
163163
column = TextColumn(fragments, column_width=width, fontsize=fontsize, justify=False)
164164

165-
text, x, dx, y, dy = column.to_bounding_boxes()
166-
svg = fm.render_svg(text, x, y, fontsize=fontsize, canvas_width=width)
165+
text, _, dx, x_origin, _, dy, y_origin = column.to_bounding_boxes()
166+
svg = fm.render_svg(text, x_origin, y_origin, fontsize=fontsize, canvas_width=width)
167167
with open(DIR / "text.svg", "w") as f:
168168
f.write(svg)
169169

@@ -179,8 +179,8 @@ def test_wrap_font_justified():
179179
fragments = f(text)
180180
column = TextColumn(fragments, column_width=width, fontsize=fontsize, justify=True)
181181

182-
text, x, dx, y, dy = column.to_bounding_boxes()
183-
svg = fm.render_svg(text, x, y, fontsize=fontsize, canvas_width=width)
182+
text, _, dx, x_origin, _, dy, y_origin = column.to_bounding_boxes()
183+
svg = fm.render_svg(text, x_origin, y_origin, fontsize=fontsize, canvas_width=width)
184184
with open(DIR / "text-justified.svg", "w") as f:
185185
f.write(svg)
186186

@@ -195,8 +195,8 @@ def test_heterogeneous_widths():
195195
fragments = f(text)
196196
column = TextColumn(fragments, column_width=width, fontsize=fontsize, justify=True)
197197

198-
text, x, dx, y, dy = column.to_bounding_boxes()
199-
svg = fm.render_svg(text, x, y, fontsize=fontsize, canvas_width=46 * fontsize)
198+
text, _, dx, x_origin, _, dy, y_origin = column.to_bounding_boxes()
199+
svg = fm.render_svg(text, x_origin, y_origin, fontsize=fontsize, canvas_width=46 * fontsize)
200200
with open(DIR / "text-heterogeneous.svg", "w") as f:
201201
f.write(svg)
202202

@@ -211,8 +211,8 @@ def test_wrap_force_newline_and_tabs():
211211
fragments = f(text)
212212
column = TextColumn(fragments, column_width=width, fontsize=fontsize, justify=True)
213213

214-
text, x, dx, y, dy = column.to_bounding_boxes()
215-
svg = fm.render_svg(text, x, y, fontsize=fontsize, canvas_width=width)
214+
text, _, dx, x_origin, _, dy, y_origin = column.to_bounding_boxes()
215+
svg = fm.render_svg(text, x_origin, y_origin, fontsize=fontsize, canvas_width=width)
216216
with open(DIR / "text-force-newline-and-tabs.svg", "w") as f:
217217
f.write(svg)
218218

@@ -232,8 +232,8 @@ def test_wrap_font_selection():
232232
fragments = f(text)
233233
column = TextColumn(fragments, column_width=width, fontsize=fontsize, justify=True)
234234

235-
text, x, dx, y, dy = column.to_bounding_boxes(line_spacing=1.2)
236-
svg = fm.render_svg(text, x, y, fontsize=fontsize, canvas_width=width)
235+
text, x, dx, x_origin, y, dy, y_origin = column.to_bounding_boxes(line_spacing=1.2)
236+
svg = fm.render_svg(text, x_origin, y_origin, fontsize=fontsize, canvas_width=width)
237237

238238
boundaries = re.compile(r"\t|\b[^\s]")
239239
separators = np.array([x.span()[0] for x in boundaries.finditer(text)], dtype=int)

textshape/layout.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ def column_xy(self) -> tuple[np.ndarray, np.ndarray]:
8585
"""
8686

8787
x = self.page_left_margin + np.pad(np.cumsum(self.column_widths[:-1] + self.column_spacing), (1, 0))
88-
y = np.full_like(x, -self.page_top_margin)
88+
y = np.full_like(x, self.page_top_margin)
8989

9090
return x, y
9191

@@ -101,23 +101,27 @@ def to_bounding_boxes(
101101

102102
line_height = multi_column.fragments.measure.line_gap * multi_column.fontsize * line_spacing
103103
max_lines = self.max_lines_per_column(line_height)
104-
text, x, dx, y, dy, c = multi_column.to_bounding_boxes(
104+
text, x, dx, x_orig, y, dy, y_orig, c = multi_column.to_bounding_boxes(
105105
max_lines_per_column=max_lines,
106106
line_spacing=line_spacing
107107
)
108108

109109
x_move, y_move = self.column_xy()
110110

111-
x = x + x_move[c % self.columns]
112-
y = y + y_move[c % self.columns]
111+
x += x_move[c % self.columns]
112+
x_orig += x_move[c % self.columns]
113+
y += y_move[c % self.columns]
114+
y_orig += y_move[c % self.columns]
113115

114116
p = c // self.columns
115117

116118
return (
117119
text,
118120
x,
119121
dx,
122+
x_orig,
120123
y,
121124
dy,
125+
y_orig,
122126
p, # page index
123127
)

textshape/shape.py

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,8 @@ def character_widths(
7575
def render_svg(
7676
self,
7777
text: str,
78-
x: FloatVector,
79-
y: FloatVector,
78+
x_origin: FloatVector,
79+
y_origin: FloatVector,
8080
fontsize: float,
8181
canvas_width: float,
8282
canvas_height: Optional[float] = None,
@@ -90,14 +90,11 @@ def render_svg(
9090
vhb = self.vhb
9191

9292
s = fontsize / self.em
93-
x = x / s
94-
y = y / s
9593

9694
font_extents = vhb.hbfont.get_font_extents("ltr")
9795
line_gap = (
9896
font_extents.line_gap or font_extents.ascender - font_extents.descender
9997
) * s
100-
y -= font_extents.descender
10198

10299
x_cursor = 0
103100
y_cursor = 0
@@ -113,8 +110,8 @@ def render_svg(
113110
cluster = info.cluster
114111

115112
if cluster > prev_cluster:
116-
x_cursor = x[cluster]
117-
y_cursor = y[cluster]
113+
x_cursor = x_origin[cluster]
114+
y_cursor = y_origin[cluster]
118115
else:
119116
# Something interesting with clustering has happened. Advance according to harfbuzz suggestion.
120117
x_cursor += prev_x_advance
@@ -136,33 +133,32 @@ def render_svg(
136133

137134
# Add a empty border and rescale
138135
x_min = 0
139-
y_max = 0
136+
y_min = 0
140137
x_max = canvas_width
141138
if canvas_height is not None:
142-
y_min = -canvas_height
139+
y_max = canvas_height
143140
else:
144-
y_min = (y.min() + font_extents.descender) * s
141+
y_max = y_origin.max() - font_extents.descender * s
145142

146-
x_min = x_min - line_gap
147-
y_min = y_min - line_gap
148-
x_max = x_max + line_gap
149-
y_max = y_max + line_gap
143+
x_min = x_min - 10
144+
y_min = y_min - 10
145+
x_max = x_max + 10
146+
y_max = y_max + 10
150147

151148
svg = [
152-
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{x_min} {y_min} {x_max - x_min} {y_max - y_min}" transform="matrix(1 0 0 -1 0 0)">',
149+
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{x_min} {y_min} {x_max - x_min} {y_max - y_min}">',
153150
f'<rect x="{x_min}" y="{y_min}" width="{x_max - x_min}" height="{y_max - y_min}" fill="#BBBBBB"/>',
154-
f'<rect x="{x_min + line_gap}" y="{y_min + line_gap}" width="{x_max - x_min - 2*line_gap}" height="{y_max - y_min - 2*line_gap}" fill="#FFFFFF"/>',
151+
f'<rect x="{x_min + 10}" y="{y_min + 10}" width="{x_max - x_min - 2*10}" height="{y_max - y_min - 2*10}" fill="#FFFFFF"/>',
155152
"<defs>",
156153
*defs.values(),
157154
"</defs>",
158-
f'<g transform="scale({s}, {s})">',
159155
*paths,
160-
"</g>",
161156
"</svg>",
162157
"",
163158
]
164159

165-
return "\n".join(svg)
160+
svg = "\n".join(svg)
161+
return svg.replace('<use', f'<use transform="scale({s}, {-s})"')
166162

167163

168164
def monospace_measure(s: str) -> FloatVector:

textshape/text.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -120,15 +120,21 @@ def _convert_and_scale(
120120
dy: FloatVector,
121121
*args,
122122
) -> CharInfoVectors:
123-
"""Convert the text to a string and scale the coordinates by the fontsize."""
123+
"""Convert the text to a string and scale the coordinates by the fontsize.
124+
125+
Additionally add the x_orig and y_orig coordinates as the glyph origin coordinates, which are needed
126+
for correct placement of glyphs.
127+
"""
124128
text = self._array_to_text(text_vector)
125129
fontsize = self.fontsize
126130
return (
127131
text,
128132
x * fontsize,
129133
dx * fontsize,
130-
y * fontsize,
134+
x * fontsize, # x_orig is the right edge of the character box, which is the origin for glyph placement
135+
(y - self.fragments.measure.ascender) * fontsize,
131136
dy * fontsize,
137+
y * fontsize,
132138
*args,
133139
)
134140

@@ -232,8 +238,8 @@ def calc_y(
232238

233239
line_gap = self.fragments.measure.line_gap
234240
y = np.zeros_like(widths)
235-
y[0] = -line_gap
236-
y[linebreaks] -= line_gap * line_spacing
241+
y[0] = self.fragments.measure.ascender
242+
y[linebreaks] = line_gap * line_spacing
237243
y = y.cumsum()
238244
dy = np.full_like(y, line_gap)
239245
return dy, y
@@ -320,10 +326,10 @@ def to_bounding_boxes(
320326
# Reset y-coordinates for each column
321327
if reset_y:
322328
splits = np.pad(linebreaks[split_mask], (1, 0))
323-
y_offset = np.repeat(y[splits], np.diff(splits, append=len(y)))
324-
y = y - y_offset - self.fragments.measure.line_gap
329+
y_offset = np.repeat(y[splits], np.diff(splits, append=len(y))) - y[0]
330+
y -= y_offset
325331

326-
# Drop trailing empty lines from each column
332+
# Drop trailing empty line characters from columns
327333
drop_mask = np.ones(len(text), dtype=bool)
328334
drop_mask[linebreaks[_drop_mask]] = False
329335
text = text[drop_mask]

textshape/types.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@
1212

1313
CharInfoVectors = tuple[
1414
str, # text
15+
FloatVector, # x_origin
16+
FloatVector, # y_origin
1517
FloatVector, # x
16-
FloatVector, # y
1718
FloatVector, # dx (width)
18-
FloatVector, # dy (height)
19+
FloatVector, # y
20+
FloatVector, # dy (height),
21+
...
1922
]

0 commit comments

Comments
 (0)