tractatus/pptx-env/lib/python3.12/site-packages/weasyprint/draw/text.py
TheFlow 2298d36bed fix(submissions): restructure Economist package and fix article display
- Create Economist SubmissionTracking package correctly:
  * mainArticle = full blog post content
  * coverLetter = 216-word SIR— letter
  * Links to blog post via blogPostId
- Archive 'Letter to The Economist' from blog posts (it's the cover letter)
- Fix date display on article cards (use published_at)
- Target publication already displaying via blue badge

Database changes:
- Make blogPostId optional in SubmissionTracking model
- Economist package ID: 68fa85ae49d4900e7f2ecd83
- Le Monde package ID: 68fa2abd2e6acd5691932150

Next: Enhanced modal with tabs, validation, export

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 08:47:42 +13:00

274 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 FROM_UNITS, TO_UNITS, ffi, pango
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
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']
# Dont 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 'underline' in text_decoration_values or 'overline' in text_decoration_values:
if textbox.style['text_decoration_thickness'] in ('auto', 'from-font'):
thickness = textbox.pango_layout.underline_thickness
elif textbox.style['text_decoration_thickness'].unit == '%':
ratio = textbox.style['text_decoration_thickness'].value / 100
thickness = textbox.style['font_size'] * ratio
else:
thickness = textbox.style['text_decoration_thickness'].value
if 'overline' in text_decoration_values:
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:
if textbox.style['text_underline_offset'] == 'auto':
underline_offset = - textbox.pango_layout.underline_position
elif textbox.style['text_underline_offset'].unit == '%':
ratio = textbox.style['text_underline_offset'].value / 100
underline_offset = textbox.style['font_size'] * ratio
else:
underline_offset = textbox.style['text_underline_offset'].value
offset_y = textbox.baseline + underline_offset + 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(textbox.style['color'])
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 stream.stacked():
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``."""
# Dont draw lines with only invisible characters.
if not textbox.text.strip():
return []
if textbox.style['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, int(max_width * TO_UNITS))
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()
stream.set_text_matrix(*matrix.values)
previous_pango_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
# Get positions of the glyphs in the UTF-8 string.
utf8_positions = [offset + clusters[i] for i in range(num_glyphs)]
if glyph_item.item.analysis.level % 2:
utf8_positions.insert(0, offset + glyph_item.item.length) # rtl
else:
utf8_positions.append(offset + glyph_item.item.length) # ltr
pango_font = glyph_item.item.analysis.font
if pango_font != previous_pango_font:
# Add font file content and get font size.
previous_pango_font = pango_font
font, font_size = stream.add_font(pango_font)
# Workaround for https://gitlab.gnome.org/GNOME/pango/-/issues/530.
if pango.pango_version() < 14802:
font_size = textbox.style['font_size']
# Go through the run glyphs.
if string:
stream.show_text(string)
string = ''
stream.set_font_size(font.hash, 1 if font.bitmap else font_size)
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
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] = round(
stream.logical_rect.width * 1000 * FROM_UNITS / font_size)
# Set kerning, word spacing, letter spacing.
kerning = int(
font.widths[glyph] + offset - width * 1000 * FROM_UNITS / font_size)
if kerning:
string += f'>{kerning}<'
# Create mapping between glyphs and characters.
if glyph not in font.cmap:
utf8_slice = slice(*sorted(utf8_positions[i:i+2]))
font.cmap[glyph] = utf8_text[utf8_slice].decode()
# 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 = -stream.logical_rect.y
f = f * FROM_UNITS / 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 ``pdf.stream.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)