Skip to content

Commit

Permalink
Split draw module into multiple submodules
Browse files Browse the repository at this point in the history
  • Loading branch information
liZe committed Jun 8, 2024
1 parent dd57e17 commit 6e93e95
Show file tree
Hide file tree
Showing 9 changed files with 1,527 additions and 1,514 deletions.
1,511 changes: 0 additions & 1,511 deletions weasyprint/draw.py

This file was deleted.

535 changes: 535 additions & 0 deletions weasyprint/draw/__init__.py

Large diffs are not rendered by default.

673 changes: 673 additions & 0 deletions weasyprint/draw/border.py

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions weasyprint/draw/color.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Draw colors."""

from colorsys import hsv_to_rgb, rgb_to_hsv


def get_color(style, key):
"""Return color, taking care of possible currentColor value."""
value = style[key]
return value if value != 'currentColor' else style['color']


def darken(color):
"""Return a darker color."""
hue, saturation, value = rgb_to_hsv(color.red, color.green, color.blue)
value /= 1.5
saturation /= 1.25
return (*hsv_to_rgb(hue, saturation, value), color.alpha)


def lighten(color):
"""Return a lighter color."""
hue, saturation, value = rgb_to_hsv(color.red, color.green, color.blue)
value = 1 - (1 - value) / 1.5
if saturation:
saturation = 1 - (1 - saturation) / 1.25
return (*hsv_to_rgb(hue, saturation, value), color.alpha)


def styled_color(style, color, side):
"""Return inset, outset, ridge and groove border colors."""
if style in ('inset', 'outset'):
do_lighten = (side in ('top', 'left')) ^ (style == 'inset')
return (lighten if do_lighten else darken)(color)
elif style in ('ridge', 'groove'):
if (side in ('top', 'left')) ^ (style == 'ridge'):
return lighten(color), darken(color)
else:
return darken(color), lighten(color)
return color
13 changes: 13 additions & 0 deletions weasyprint/draw/stack.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Drawing stack context manager."""

from contextlib import contextmanager


@contextmanager
def stacked(stream):
"""Save and restore stream context when used with the ``with`` keyword."""
stream.push_state()
try:
yield
finally:
stream.pop_state()
264 changes: 264 additions & 0 deletions weasyprint/draw/text.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
"""Draw text."""

from io import BytesIO
from xml.etree import ElementTree

from PIL import Image

from ..images import RasterImage, SVGImage
from ..matrix import Matrix
from ..text.ffi import ffi, pango, units_from_double, units_to_double
from ..text.fonts import get_hb_object_data
from ..text.line_break import get_last_word_end
from .border import draw_line
from .color import get_color
from .stack import stacked


def draw_text(stream, textbox, offset_x, text_overflow, block_ellipsis):
"""Draw a textbox to a pydyf stream."""
# Pango crashes with font-size: 0.
assert textbox.style['font_size']

# Don’t draw invisible textboxes.
if textbox.style['visibility'] != 'visible':
return

# Draw underline and overline.
text_decoration_values = textbox.style['text_decoration_line']
text_decoration_color = get_color(textbox.style, 'text_decoration_color')
if 'overline' in text_decoration_values:
thickness = textbox.pango_layout.underline_thickness
offset_y = (
textbox.baseline - textbox.pango_layout.ascent + thickness / 2)
draw_text_decoration(
stream, textbox, offset_x, offset_y, thickness,
text_decoration_color)
if 'underline' in text_decoration_values:
thickness = textbox.pango_layout.underline_thickness
offset_y = (
textbox.baseline - textbox.pango_layout.underline_position +
thickness / 2)
draw_text_decoration(
stream, textbox, offset_x, offset_y, thickness,
text_decoration_color)

# Draw text.
x, y = textbox.position_x, textbox.position_y + textbox.baseline
stream.set_color_rgb(*textbox.style['color'][:3])
stream.set_alpha(textbox.style['color'][3])
textbox.pango_layout.reactivate(textbox.style)
stream.begin_text()
emojis = draw_first_line(
stream, textbox, text_overflow, block_ellipsis, Matrix(d=-1, e=x, f=y))
stream.end_text()

# Draw emojis.
draw_emojis(stream, textbox.style['font_size'], x, y, emojis)

# Draw line through.
if 'line-through' in text_decoration_values:
thickness = textbox.pango_layout.strikethrough_thickness
offset_y = textbox.baseline - textbox.pango_layout.strikethrough_position
draw_text_decoration(
stream, textbox, offset_x, offset_y, thickness, text_decoration_color)
textbox.pango_layout.deactivate()


def draw_emojis(stream, font_size, x, y, emojis):
"""Draw list of emojis."""
for image, font, a, d, e, f in emojis:
with stacked(stream):
stream.transform(a=a, d=d, e=x + e * font_size, f=y + f)
image.draw(stream, font_size, font_size, None)


def draw_first_line(stream, textbox, text_overflow, block_ellipsis, matrix):
"""Draw the given ``textbox`` line to the document ``stream``."""
# Don’t draw lines with only invisible characters.
if not textbox.text.strip():
return []

font_size = textbox.style['font_size']
if font_size < 1e-6: # default float precision used by pydyf
return []

pango.pango_layout_set_single_paragraph_mode(textbox.pango_layout.layout, True)

if text_overflow == 'ellipsis' or block_ellipsis != 'none':
assert textbox.pango_layout.max_width is not None
max_width = textbox.pango_layout.max_width
pango.pango_layout_set_width(
textbox.pango_layout.layout, units_from_double(max_width))
if text_overflow == 'ellipsis':
pango.pango_layout_set_ellipsize(
textbox.pango_layout.layout, pango.PANGO_ELLIPSIZE_END)
else:
if block_ellipsis == 'auto':
ellipsis = '…'
else:
assert block_ellipsis[0] == 'string'
ellipsis = block_ellipsis[1]

# Remove last word if hyphenated.
new_text = textbox.pango_layout.text
if new_text.endswith(textbox.style['hyphenate_character']):
last_word_end = get_last_word_end(
new_text[:-len(textbox.style['hyphenate_character'])],
textbox.style['lang'])
if last_word_end:
new_text = new_text[:last_word_end]

textbox.pango_layout.set_text(new_text + ellipsis)

first_line, index = textbox.pango_layout.get_first_line()

if block_ellipsis != 'none':
while index:
last_word_end = get_last_word_end(
textbox.pango_layout.text[:-len(ellipsis)],
textbox.style['lang'])
if last_word_end is None:
break
new_text = textbox.pango_layout.text[:last_word_end]
textbox.pango_layout.set_text(new_text + ellipsis)
first_line, index = textbox.pango_layout.get_first_line()

utf8_text = textbox.pango_layout.text.encode()
previous_utf8_position = 0
stream.text_matrix(*matrix.values)
last_font = None
string = ''
x_advance = 0
emojis = []
run = first_line.runs[0]
while run != ffi.NULL:
# Get Pango objects.
glyph_item = run.data
run = run.next
glyph_string = glyph_item.glyphs
glyphs = glyph_string.glyphs
num_glyphs = glyph_string.num_glyphs
offset = glyph_item.item.offset
clusters = glyph_string.log_clusters

# Add font file content.
pango_font = glyph_item.item.analysis.font
font = stream.add_font(pango_font)

# Get positions of the glyphs in the UTF-8 string.
utf8_positions = [offset + clusters[i] for i in range(1, num_glyphs)]
utf8_positions.append(offset + glyph_item.item.length)

# Go through the run glyphs.
if font != last_font:
if string:
stream.show_text(string)
string = ''
stream.set_font_size(font.hash, 1 if font.bitmap else font_size)
last_font = font
string += '<'
for i in range(num_glyphs):
glyph_info = glyphs[i]
glyph = glyph_info.glyph
width = glyph_info.geometry.width
if (glyph == pango.PANGO_GLYPH_EMPTY or
glyph & pango.PANGO_GLYPH_UNKNOWN_FLAG):
string += f'>{-width / font_size}<'
continue
utf8_position = utf8_positions[i]

offset = glyph_info.geometry.x_offset / font_size
rise = glyph_info.geometry.y_offset / 1000
if rise:
if string[-1] == '<':
string = string[:-1]
else:
string += '>'
stream.show_text(string)
stream.set_text_rise(-rise)
string = ''
if offset:
string = f'{-offset}'
string += f'<{glyph:02x}>' if font.bitmap else f'<{glyph:04x}>'
stream.show_text(string)
stream.set_text_rise(0)
string = '<'
else:
if offset:
string += f'>{-offset}<'
string += f'{glyph:02x}' if font.bitmap else f'{glyph:04x}'

# Get ink bounding box and logical widths in font.
if glyph not in font.widths:
pango.pango_font_get_glyph_extents(
pango_font, glyph, stream.ink_rect, stream.logical_rect)
font.widths[glyph] = int(round(
units_to_double(stream.logical_rect.width * 1000) /
font_size))

# Set kerning, word spacing, letter spacing.
kerning = int(
font.widths[glyph] - units_to_double(width * 1000) / font_size + offset)
if kerning:
string += f'>{kerning}<'

# Create mapping between glyphs and characters.
if glyph not in font.cmap:
utf8_slice = slice(previous_utf8_position, utf8_position)
font.cmap[glyph] = utf8_text[utf8_slice].decode()
previous_utf8_position = utf8_position

# Create list of emojis.
if font.svg:
svg_data = get_hb_object_data(font.hb_face, 'svg', glyph)
if svg_data:
# Do as explained in specification
# https://learn.microsoft.com/typography/opentype/spec/svg
tree = ElementTree.fromstring(svg_data)
defs = ElementTree.Element('defs')
for child in list(tree):
defs.append(child)
tree.remove(child)
tree.append(defs)
ElementTree.SubElement(
tree, 'use', attrib={'href': f'#glyph{glyph}'})
image = SVGImage(tree, None, None, stream)
a = d = font.widths[glyph] / 1000 / font.upem * font_size
emojis.append([image, font, a, d, x_advance, 0])
elif font.png:
png_data = get_hb_object_data(font.hb_font, 'png', glyph)
if png_data:
pillow_image = Image.open(BytesIO(png_data))
image_id = f'{font.hash}{glyph}'
image = RasterImage(pillow_image, image_id, png_data)
d = font.widths[glyph] / 1000
a = pillow_image.width / pillow_image.height * d
pango.pango_font_get_glyph_extents(
pango_font, glyph, stream.ink_rect,
stream.logical_rect)
f = units_to_double(
(-stream.logical_rect.y - stream.logical_rect.height))
f = f / font_size - font_size
emojis.append([image, font, a, d, x_advance, f])

x_advance += (font.widths[glyph] + offset - kerning) / 1000

# Close the last glyphs list, remove if empty.
if string[-1] == '<':
string = string[:-1]
else:
string += '>'

# Draw text.
stream.show_text(string)

return emojis


def draw_text_decoration(stream, textbox, offset_x, offset_y, thickness, color):
"""Draw text-decoration of ``textbox`` to a ``document.Stream``."""
draw_line(
stream, textbox.position_x, textbox.position_y + offset_y,
textbox.position_x + textbox.width, textbox.position_y + offset_y,
thickness, textbox.style['text_decoration_style'], color, offset_x)
2 changes: 1 addition & 1 deletion weasyprint/layout/background.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def box_rectangle(box, which_rectangle):
def layout_box_backgrounds(page, box, get_image_from_uri, layout_children=True,
style=None):
"""Fetch and position background images."""
from ..draw import get_color
from ..draw.color import get_color

# Resolve percentages in border-radius properties
resolve_radii_percentages(box)
Expand Down
2 changes: 1 addition & 1 deletion weasyprint/layout/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -949,7 +949,7 @@ def collapse_table_borders(table, grid_width, grid_height):
[weak_null_border] * grid_width for _ in range(grid_height + 1)]

def set_one_border(border_grid, box_style, side, grid_x, grid_y):
from ..draw import get_color
from ..draw.color import get_color

style = box_style[f'border_{side}_style']
width = box_style[f'border_{side}_width']
Expand Down
2 changes: 1 addition & 1 deletion weasyprint/svg/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def text(self):
def text(svg, node, font_size):
"""Draw text node."""
from ..css.properties import INITIAL_VALUES
from ..draw import draw_emojis, draw_first_line
from ..draw.text import draw_emojis, draw_first_line
from ..text.line_break import split_first_line

# TODO: use real computed values
Expand Down

0 comments on commit 6e93e95

Please sign in to comment.