- 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>
539 lines
22 KiB
Python
539 lines
22 KiB
Python
"""Take an "after layout" box tree and draw it onto a pydyf stream."""
|
||
|
||
import operator
|
||
from math import floor
|
||
from xml.etree import ElementTree
|
||
|
||
from ..formatting_structure import boxes
|
||
from ..images import SVGImage
|
||
from ..layout import replaced
|
||
from ..layout.background import BackgroundLayer
|
||
from ..matrix import Matrix
|
||
from ..stacking import StackingContext
|
||
from .border import draw_border, draw_line, draw_outline, rounded_box, set_mask_border
|
||
from .color import styled_color
|
||
from .text import draw_text
|
||
|
||
|
||
def draw_page(page, stream):
|
||
"""Draw the given PageBox."""
|
||
marks = page.style['marks']
|
||
stacking_context = StackingContext.from_page(page)
|
||
draw_background(
|
||
stream, stacking_context.box.background, clip_box=False, bleed=page.bleed,
|
||
marks=marks)
|
||
set_mask_border(stream, page)
|
||
draw_background(stream, page.canvas_background, clip_box=False)
|
||
draw_border(stream, page)
|
||
draw_stacking_context(stream, stacking_context)
|
||
|
||
|
||
def draw_stacking_context(stream, stacking_context):
|
||
"""Draw a ``stacking_context`` on ``stream``."""
|
||
# See https://www.w3.org/TR/CSS2/zindex.html.
|
||
with stream.stacked():
|
||
box = stacking_context.box
|
||
|
||
# Apply the viewport_overflow to the html box, see #35.
|
||
if box.is_for_root_element and (
|
||
stacking_context.page.style['overflow'] != 'visible'):
|
||
rounded_box(stream, stacking_context.page.rounded_padding_box())
|
||
stream.clip()
|
||
stream.end()
|
||
|
||
if box.is_absolutely_positioned() and box.style['clip']:
|
||
top, right, bottom, left = box.style['clip']
|
||
if top == 'auto':
|
||
top = 0
|
||
if right == 'auto':
|
||
right = 0
|
||
if bottom == 'auto':
|
||
bottom = box.border_height()
|
||
if left == 'auto':
|
||
left = box.border_width()
|
||
stream.rectangle(
|
||
box.border_box_x() + right, box.border_box_y() + top,
|
||
left - right, bottom - top)
|
||
stream.clip()
|
||
stream.end()
|
||
|
||
if box.style['opacity'] < 1:
|
||
original_stream = stream
|
||
stream = stream.add_group(*stream.page_rectangle)
|
||
|
||
if box.transformation_matrix:
|
||
if box.transformation_matrix.determinant:
|
||
stream.transform(*box.transformation_matrix.values)
|
||
else:
|
||
return
|
||
|
||
# Point 1 is done in draw_page.
|
||
|
||
# Point 2.
|
||
if isinstance(box, (boxes.BlockBox, boxes.MarginBox, boxes.InlineBlockBox,
|
||
boxes.TableCellBox, boxes.FlexContainerBox,
|
||
boxes.GridContainerBox, boxes.ReplacedBox)):
|
||
set_mask_border(stream, box)
|
||
# The canvas background was removed by layout_backgrounds.
|
||
draw_background(stream, box.background)
|
||
draw_border(stream, box)
|
||
|
||
with stream.stacked():
|
||
# Dont clip the page box, see #35.
|
||
clip = (
|
||
box.style['overflow'] != 'visible' and
|
||
not isinstance(box, boxes.PageBox))
|
||
if clip:
|
||
# Only clip the content and the children:
|
||
# - the background is already clipped,
|
||
# - the border must *not* be clipped.
|
||
rounded_box(stream, box.rounded_padding_box())
|
||
stream.clip()
|
||
stream.end()
|
||
|
||
# Point 3.
|
||
for child_context in stacking_context.negative_z_contexts:
|
||
draw_stacking_context(stream, child_context)
|
||
|
||
# Point 4.
|
||
for block in stacking_context.block_level_boxes:
|
||
set_mask_border(stream, block)
|
||
|
||
if isinstance(block, boxes.TableBox):
|
||
draw_table(stream, block)
|
||
else:
|
||
draw_background(stream, block.background)
|
||
draw_border(stream, block)
|
||
|
||
# Point 5.
|
||
for child_context in stacking_context.float_contexts:
|
||
draw_stacking_context(stream, child_context)
|
||
|
||
# Point 6.
|
||
if isinstance(box, boxes.InlineBox):
|
||
draw_inline_level(stream, stacking_context.page, box)
|
||
|
||
# Point 7.
|
||
draw_block_level(
|
||
stacking_context.page, stream, {box: stacking_context.blocks_and_cells})
|
||
|
||
# Point 8.
|
||
for child_context in stacking_context.zero_z_contexts:
|
||
draw_stacking_context(stream, child_context)
|
||
|
||
# Point 9.
|
||
for child_context in stacking_context.positive_z_contexts:
|
||
draw_stacking_context(stream, child_context)
|
||
|
||
# Point 10.
|
||
draw_outline(stream, box)
|
||
|
||
if box.style['opacity'] < 1:
|
||
group_id = stream.id
|
||
stream = original_stream
|
||
with stream.stacked():
|
||
stream.set_alpha(box.style['opacity'], stroke=True, fill=True)
|
||
stream.draw_x_object(group_id)
|
||
|
||
|
||
def draw_background(stream, bg, clip_box=True, bleed=None, marks=()):
|
||
"""Draw the background color and image to a ``pdf.stream.Stream``.
|
||
|
||
If ``clip_box`` is set to ``False``, the background is not clipped to the
|
||
border box of the background, but only to the painting area.
|
||
|
||
"""
|
||
if bg is None:
|
||
return
|
||
|
||
with stream.stacked():
|
||
if clip_box:
|
||
for box in bg.layers[-1].clipped_boxes:
|
||
rounded_box(stream, box)
|
||
stream.clip()
|
||
stream.end()
|
||
|
||
# Draw background color.
|
||
if bg.color.alpha > 0:
|
||
with stream.artifact(), stream.stacked():
|
||
stream.set_color(bg.color)
|
||
painting_area = bg.layers[-1].painting_area
|
||
stream.rectangle(*painting_area)
|
||
stream.clip()
|
||
stream.end()
|
||
stream.rectangle(*painting_area)
|
||
stream.fill()
|
||
|
||
# Draw crop marks and crosses.
|
||
if bleed and marks:
|
||
x, y, width, height = bg.layers[-1].painting_area
|
||
half_bleed = {key: value * 0.5 for key, value in bleed.items()}
|
||
svg = f'''
|
||
<svg height="{height}" width="{width}"
|
||
fill="transparent" stroke="black" stroke-width="1"
|
||
xmlns="http://www.w3.org/2000/svg">
|
||
'''
|
||
if 'crop' in marks:
|
||
svg += f'''
|
||
<path d="M0,{bleed['top']} h{half_bleed['left']}" />
|
||
<path d="M0,{bleed['top']} h{half_bleed['right']}"
|
||
transform="translate({width},0) scale(-1,1)" />
|
||
<path d="M0,{bleed['bottom']} h{half_bleed['right']}"
|
||
transform="translate({width},{height}) scale(-1,-1)" />
|
||
<path d="M0,{bleed['bottom']} h{half_bleed['left']}"
|
||
transform="translate(0,{height}) scale(1,-1)" />
|
||
<path d="M{bleed['left']},0 v{half_bleed['top']}" />
|
||
<path d="M{bleed['right']},0 v{half_bleed['bottom']}"
|
||
transform="translate({width},{height}) scale(-1,-1)" />
|
||
<path d="M{bleed['left']},0 v{half_bleed['bottom']}"
|
||
transform="translate(0,{height}) scale(1,-1)" />
|
||
<path d="M{bleed['right']},0 v{half_bleed['top']}"
|
||
transform="translate({width},0) scale(-1,1)" />
|
||
'''
|
||
if 'cross' in marks:
|
||
svg += f'''
|
||
<circle r="{half_bleed['top']}" transform="scale(0.5)
|
||
translate({width},{half_bleed['top']}) scale(0.5)" />
|
||
<path transform="scale(0.5) translate({width},0)" d="
|
||
M-{half_bleed['top']},{half_bleed['top']} h{bleed['top']}
|
||
M0,0 v{bleed['top']}" />
|
||
<circle r="{half_bleed['bottom']}" transform="
|
||
translate(0,{height}) scale(0.5)
|
||
translate({width},-{half_bleed['bottom']}) scale(0.5)" />
|
||
<path d="M-{half_bleed['bottom']},-{half_bleed['bottom']}
|
||
h{bleed['bottom']} M0,0 v-{bleed['bottom']}" transform="
|
||
translate(0,{height}) scale(0.5) translate({width},0)" />
|
||
<circle r="{half_bleed['left']}" transform="scale(0.5)
|
||
translate({half_bleed['left']},{height}) scale(0.5)" />
|
||
<path d="M{half_bleed['left']},-{half_bleed['left']}
|
||
v{bleed['left']} M0,0 h{bleed['left']}"
|
||
transform="scale(0.5) translate(0,{height})" />
|
||
<circle r="{half_bleed['right']}" transform="
|
||
translate({width},0) scale(0.5)
|
||
translate(-{half_bleed['right']},{height}) scale(0.5)" />
|
||
<path d="M-{half_bleed['right']},-{half_bleed['right']}
|
||
v{bleed['right']} M0,0 h-{bleed['right']}" transform="
|
||
translate({width},0) scale(0.5) translate(0,{height})" />
|
||
'''
|
||
svg += '</svg>'
|
||
tree = ElementTree.fromstring(svg)
|
||
image = SVGImage(tree, None, None, stream)
|
||
# Painting area is the PDF media box
|
||
size = (width, height)
|
||
position = (x, y)
|
||
repeat = ('no-repeat', 'no-repeat')
|
||
unbounded = True
|
||
painting_area = position + size
|
||
positioning_area = (0, 0, width, height)
|
||
clipped_boxes = []
|
||
layer = BackgroundLayer(
|
||
image, size, position, repeat, unbounded, painting_area,
|
||
positioning_area, clipped_boxes)
|
||
bg.layers.insert(0, layer)
|
||
# Paint in reversed order: first layer is "closest" to the viewer.
|
||
for layer in reversed(bg.layers):
|
||
draw_background_image(stream, layer, bg.image_rendering)
|
||
|
||
|
||
def draw_background_image(stream, layer, image_rendering):
|
||
if layer.image is None or 0 in layer.size:
|
||
return
|
||
|
||
painting_x, painting_y, painting_width, painting_height = layer.painting_area
|
||
positioning_x, positioning_y, positioning_width, positioning_height = (
|
||
layer.positioning_area)
|
||
position_x, position_y = layer.position
|
||
repeat_x, repeat_y = layer.repeat
|
||
image_width, image_height = layer.size
|
||
|
||
if repeat_x == 'no-repeat' and repeat_y == 'no-repeat':
|
||
with stream.artifact():
|
||
# We don't use a pattern when we don't need to because some viewers
|
||
# (e.g., Preview on Mac) introduce unnecessary pixelation when vector
|
||
# images are used in patterns.
|
||
if not layer.unbounded:
|
||
stream.rectangle(
|
||
painting_x, painting_y, painting_width, painting_height)
|
||
stream.clip()
|
||
stream.end()
|
||
# Put the image in a group so that masking outside the image and
|
||
# masking within the image don't conflict.
|
||
group = stream.add_group(*stream.page_rectangle)
|
||
group.transform(e=position_x + positioning_x, f=position_y + positioning_y)
|
||
layer.image.draw(group, image_width, image_height, image_rendering)
|
||
stream.draw_x_object(group.id)
|
||
return
|
||
|
||
if repeat_x == 'no-repeat':
|
||
# We want at least the whole image_width drawn on sub_surface, but we
|
||
# want to be sure it will not be repeated on the painting_width. We
|
||
# double the painting width to ensure viewers don't incorrectly bleed
|
||
# the edge of the pattern into the painting area. (See #1539.)
|
||
repeat_width = max(image_width, 2 * painting_width)
|
||
elif repeat_x in ('repeat', 'round'):
|
||
# We repeat the image each image_width.
|
||
repeat_width = image_width
|
||
else:
|
||
assert repeat_x == 'space'
|
||
n_repeats = floor(positioning_width / image_width)
|
||
if n_repeats >= 2:
|
||
# The repeat width is the whole positioning width with one image
|
||
# removed, divided by (the number of repeated images - 1). This
|
||
# way, we get the width of one image + one space. We ignore
|
||
# background-position for this dimension.
|
||
repeat_width = (positioning_width - image_width) / (n_repeats - 1)
|
||
position_x = 0
|
||
else:
|
||
# We don't repeat the image.
|
||
repeat_width = positioning_width
|
||
|
||
# Comments above apply here too.
|
||
if repeat_y == 'no-repeat':
|
||
repeat_height = max(image_height, 2 * painting_height)
|
||
elif repeat_y in ('repeat', 'round'):
|
||
repeat_height = image_height
|
||
else:
|
||
assert repeat_y == 'space'
|
||
n_repeats = floor(positioning_height / image_height)
|
||
if n_repeats >= 2:
|
||
repeat_height = (positioning_height - image_height) / (n_repeats - 1)
|
||
position_y = 0
|
||
else:
|
||
repeat_height = positioning_height
|
||
|
||
matrix = Matrix(e=position_x + positioning_x, f=position_y + positioning_y)
|
||
matrix @= stream.ctm
|
||
pattern = stream.add_pattern(
|
||
0, 0, image_width, image_height, repeat_width, repeat_height, matrix)
|
||
group = pattern.add_group(0, 0, repeat_width, repeat_height)
|
||
|
||
with stream.artifact(), stream.stacked():
|
||
layer.image.draw(group, image_width, image_height, image_rendering)
|
||
with pattern.artifact():
|
||
pattern.draw_x_object(group.id)
|
||
stream.set_color_space('Pattern')
|
||
stream.set_color_special(pattern.id)
|
||
if layer.unbounded:
|
||
x1, y1, x2, y2 = stream.page_rectangle
|
||
stream.rectangle(x1, y1, x2 - x1, y2 - y1)
|
||
else:
|
||
stream.rectangle(painting_x, painting_y, painting_width, painting_height)
|
||
stream.fill()
|
||
|
||
|
||
def draw_table(stream, table):
|
||
# Draw backgrounds.
|
||
draw_background(stream, table.background)
|
||
for column_group in table.column_groups:
|
||
draw_background(stream, column_group.background)
|
||
for column in column_group.children:
|
||
draw_background(stream, column.background)
|
||
for row_group in table.children:
|
||
draw_background(stream, row_group.background)
|
||
for row in row_group.children:
|
||
draw_background(stream, row.background)
|
||
for cell in row.children:
|
||
draw_cell_background = (
|
||
table.style['border_collapse'] == 'collapse' or
|
||
cell.style['empty_cells'] == 'show' or
|
||
not cell.empty)
|
||
if draw_cell_background:
|
||
draw_background(stream, cell.background)
|
||
|
||
# Draw borders.
|
||
if table.style['border_collapse'] == 'collapse':
|
||
return draw_collapsed_borders(stream, table)
|
||
draw_border(stream, table)
|
||
for row_group in table.children:
|
||
for row in row_group.children:
|
||
for cell in row.children:
|
||
if cell.style['empty_cells'] == 'show' or not cell.empty:
|
||
draw_border(stream, cell)
|
||
|
||
|
||
def draw_collapsed_borders(stream, table):
|
||
"""Draw borders of table cells when they collapse."""
|
||
row_heights = [
|
||
row.height for row_group in table.children
|
||
for row in row_group.children]
|
||
column_widths = table.column_widths
|
||
if not (row_heights and column_widths):
|
||
# One of the list is empty: don’t bother with empty tables.
|
||
return
|
||
row_positions = [
|
||
row.position_y for row_group in table.children
|
||
for row in row_group.children]
|
||
column_positions = list(table.column_positions)
|
||
grid_height = len(row_heights)
|
||
grid_width = len(column_widths)
|
||
assert grid_width == len(column_positions)
|
||
vertical_borders, horizontal_borders = table.collapsed_border_grid
|
||
# Add the end of the last column.
|
||
column_positions.append(column_positions[-1] + column_widths[-1])
|
||
# Add the end of the last row.
|
||
row_positions.append(row_positions[-1] + row_heights[-1])
|
||
if table.children[0].is_header:
|
||
header_rows = len(table.children[0].children)
|
||
else:
|
||
header_rows = 0
|
||
if table.children[-1].is_footer:
|
||
footer_rows = len(table.children[-1].children)
|
||
else:
|
||
footer_rows = 0
|
||
skipped_rows = table.skipped_rows
|
||
if skipped_rows:
|
||
body_rows_offset = skipped_rows - header_rows
|
||
else:
|
||
body_rows_offset = 0
|
||
original_grid_height = len(vertical_borders)
|
||
footer_rows_offset = original_grid_height - grid_height
|
||
|
||
def row_number(y, horizontal):
|
||
# Examples in comments for 2 headers rows, 5 body rows, 3 footer rows.
|
||
if header_rows and y < header_rows + int(horizontal):
|
||
# Row in header: y < 2 for vertical, y < 3 for horizontal.
|
||
return y
|
||
elif footer_rows and y >= grid_height - footer_rows - int(horizontal):
|
||
# Row in footer: y >= 7 for vertical, y >= 6 for horizontal.
|
||
return y + footer_rows_offset
|
||
else:
|
||
# Row in body: 2 >= y > 7 for vertical, 3 >= y > 6 for horizontal.
|
||
return y + body_rows_offset
|
||
|
||
segments = []
|
||
|
||
def half_max_width(border_list, yx_pairs, vertical=True):
|
||
result = 0
|
||
for y, x in yx_pairs:
|
||
if vertical:
|
||
inside = 0 <= y < grid_height and 0 <= x <= grid_width
|
||
else:
|
||
inside = 0 <= y <= grid_height and 0 <= x < grid_width
|
||
if inside:
|
||
yy = row_number(y, horizontal=not vertical)
|
||
_, (_, width, _) = border_list[yy][x]
|
||
result = max(result, width)
|
||
return result / 2
|
||
|
||
def add_vertical(x, y):
|
||
yy = row_number(y, horizontal=False)
|
||
score, (style, width, color) = vertical_borders[yy][x]
|
||
if width == 0 or color.alpha == 0:
|
||
return
|
||
pos_x = column_positions[x]
|
||
pos_y1 = row_positions[y]
|
||
if y != 0 or not table.skip_cell_border_top:
|
||
pos_y1 -= half_max_width(
|
||
horizontal_borders, [(y, x - 1), (y, x)], vertical=False)
|
||
pos_y2 = row_positions[y + 1]
|
||
if y != grid_height - 1 or not table.skip_cell_border_bottom:
|
||
pos_y2 += half_max_width(
|
||
horizontal_borders, [(y + 1, x - 1), (y + 1, x)], vertical=False)
|
||
segments.append((
|
||
score, style, width, color, 'left', (pos_x, pos_y1, 0, pos_y2 - pos_y1)))
|
||
|
||
def add_horizontal(x, y):
|
||
if y == 0 and table.skip_cell_border_top:
|
||
return
|
||
if y == grid_height and table.skip_cell_border_bottom:
|
||
return
|
||
yy = row_number(y, horizontal=True)
|
||
score, (style, width, color) = horizontal_borders[yy][x]
|
||
if width == 0 or color.alpha == 0:
|
||
return
|
||
pos_y = row_positions[y]
|
||
shift_before = half_max_width(vertical_borders, [(y - 1, x), (y, x)])
|
||
shift_after = half_max_width(vertical_borders, [(y - 1, x + 1), (y, x + 1)])
|
||
pos_x1 = column_positions[x] - shift_before
|
||
pos_x2 = column_positions[x + 1] + shift_after
|
||
segments.append((
|
||
score, style, width, color, 'top', (pos_x1, pos_y, pos_x2 - pos_x1, 0)))
|
||
|
||
for x in range(grid_width):
|
||
add_horizontal(x, 0)
|
||
for y in range(grid_height):
|
||
add_vertical(0, y)
|
||
for x in range(grid_width):
|
||
add_vertical(x + 1, y)
|
||
add_horizontal(x, y + 1)
|
||
|
||
# Sort bigger scores last (painted later, on top).
|
||
segments.sort(key=operator.itemgetter(0))
|
||
|
||
for segment in segments:
|
||
_, style, width, color, side, border_box = segment
|
||
bx, by, bw, bh = border_box
|
||
color = styled_color(style, color, side)
|
||
with stream.artifact(), stream.stacked():
|
||
draw_line(stream, bx, by, bx + bw, by + bh, width, style, color)
|
||
|
||
|
||
def draw_replacedbox(stream, box):
|
||
"""Draw the given :class:`boxes.ReplacedBox` to a ``pdf.stream.Stream``."""
|
||
if box.style['visibility'] != 'visible' or not box.width or not box.height:
|
||
return
|
||
|
||
draw_width, draw_height, draw_x, draw_y = replaced.replacedbox_layout(box)
|
||
if draw_width <= 0 or draw_height <= 0:
|
||
return
|
||
|
||
with stream.stacked():
|
||
stream.set_alpha(1)
|
||
stream.transform(e=draw_x, f=draw_y)
|
||
with stream.stacked():
|
||
# TODO: Use the real intrinsic size here, not affected by
|
||
# 'image-resolution'?
|
||
box.replacement.draw(
|
||
stream, draw_width, draw_height, box.style['image_rendering'])
|
||
|
||
|
||
def draw_inline_level(stream, page, box, offset_x=0, text_overflow='clip',
|
||
block_ellipsis='none'):
|
||
if isinstance(box, StackingContext):
|
||
stacking_context = box
|
||
allowed_boxes = (boxes.InlineBlockBox, boxes.InlineFlexBox, boxes.InlineGridBox)
|
||
assert isinstance(stacking_context.box, allowed_boxes)
|
||
draw_stacking_context(stream, stacking_context)
|
||
else:
|
||
set_mask_border(stream, box)
|
||
draw_background(stream, box.background)
|
||
draw_border(stream, box)
|
||
if isinstance(box, (boxes.InlineBox, boxes.LineBox)):
|
||
if isinstance(box, boxes.LineBox):
|
||
text_overflow = box.text_overflow
|
||
block_ellipsis = box.block_ellipsis
|
||
ellipsis = 'none'
|
||
for i, child in enumerate(box.children):
|
||
if i == len(box.children) - 1:
|
||
# Last child
|
||
ellipsis = block_ellipsis
|
||
if isinstance(child, StackingContext):
|
||
child_offset_x = offset_x
|
||
else:
|
||
child_offset_x = offset_x + child.position_x - box.position_x
|
||
if isinstance(child, boxes.TextBox):
|
||
with stream.marked(child, 'Span'):
|
||
draw_text(
|
||
stream, child, child_offset_x, text_overflow, ellipsis)
|
||
else:
|
||
draw_inline_level(
|
||
stream, page, child, child_offset_x, text_overflow, ellipsis)
|
||
elif isinstance(box, boxes.InlineReplacedBox):
|
||
with stream.marked(box, 'Figure'):
|
||
draw_replacedbox(stream, box)
|
||
else:
|
||
assert isinstance(box, boxes.TextBox)
|
||
# Should only happen for list markers.
|
||
draw_text(stream, box, offset_x, text_overflow)
|
||
|
||
|
||
def draw_block_level(page, stream, blocks_and_cells):
|
||
for block, blocks_and_cells in blocks_and_cells.items():
|
||
if isinstance(block, boxes.ReplacedBox):
|
||
with stream.marked(block, 'Figure'):
|
||
draw_replacedbox(stream, block)
|
||
elif block.children:
|
||
if isinstance(block.children[-1], boxes.LineBox):
|
||
for child in block.children:
|
||
draw_inline_level(stream, page, child)
|
||
draw_block_level(page, stream, blocks_and_cells)
|