268 lines
9.7 KiB
Python
268 lines
9.7 KiB
Python
import logging
|
|
import os
|
|
from dataclasses import dataclass
|
|
from functools import cached_property
|
|
from math import ceil
|
|
|
|
from docx.enum.text import WD_LINE_SPACING
|
|
from docx.oxml import CT_R
|
|
from docx.text.run import Run
|
|
from freetype import Face
|
|
|
|
from docx.text.paragraph import Paragraph
|
|
from docx.text.font import Font as DocxFont
|
|
from docx.shared import Length, Pt, Inches
|
|
from docx.text.parfmt import ParagraphFormat
|
|
from docx.styles.style import _ParagraphStyle
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
from .find_font import find_font
|
|
|
|
|
|
def _merge_objects(*objects):
|
|
from inspect import ismethod
|
|
"""
|
|
Returns the new object containing attributes from objects, where the latest
|
|
one has the highest priority.
|
|
"""
|
|
|
|
class MergedObject:
|
|
pass
|
|
|
|
merged_object = MergedObject()
|
|
|
|
def safe_attrs(obj):
|
|
names = set(dir(obj))
|
|
for name in names:
|
|
if name.startswith("_"):
|
|
continue
|
|
try:
|
|
value = getattr(obj, name)
|
|
except (AttributeError, ValueError, TypeError):
|
|
continue
|
|
if ismethod(value) or callable(value):
|
|
continue
|
|
yield name, value
|
|
|
|
for name, value in safe_attrs(objects[0]):
|
|
merged_object.__setattr__(name, value)
|
|
|
|
for object_ in objects[1:]:
|
|
for name, value in safe_attrs(object_):
|
|
if value is not None:
|
|
merged_object.__setattr__(name, value)
|
|
|
|
return merged_object
|
|
|
|
|
|
class Font:
|
|
def __init__(self, name: str, bold: bool, italic: bool, size_pt: int):
|
|
path = find_font(name, bold, italic)
|
|
self._freetypefont = ImageFont.truetype(path, size_pt)
|
|
self._draw = ImageDraw.Draw(Image.new("RGB", (1000, 1000)))
|
|
|
|
self._face = Face(path)
|
|
self._face.set_char_size(int(size_pt * 64))
|
|
|
|
def get_text_width(self, text: str) -> Length:
|
|
if not self.is_mono:
|
|
bbox = self._draw.textbbox((0, 0), text, self._freetypefont)
|
|
return Pt(bbox[2] - bbox[0])
|
|
else:
|
|
return Pt(len(text) * self._face.glyph.advance.x / 64)
|
|
|
|
def get_line_height(self) -> Length:
|
|
# TODO: make it work for all fonts
|
|
if "Times" in str(self._face.family_name) and self._freetypefont.size == 14:
|
|
return Pt(16.05)
|
|
if "Courier" in str(self._face.family_name) and self._freetypefont.size == 12:
|
|
return Pt(13.61)
|
|
else:
|
|
return Pt(self._face.size.height / 64)
|
|
@cached_property
|
|
def is_mono(self):
|
|
self._face.load_char("i")
|
|
i_width = self._face.glyph.advance.x
|
|
self._face.load_char("m")
|
|
return i_width == self._face.glyph.advance.x
|
|
# return self._face.glyph.bitmap.width
|
|
|
|
|
|
|
|
@dataclass
|
|
class ParagraphSizerResult:
|
|
before: Length
|
|
lines: int
|
|
line_height: Length
|
|
line_spacing: float
|
|
after: Length
|
|
|
|
@property
|
|
def base(self) -> Length:
|
|
return self.before + ((self.lines - 1) * self.line_spacing + 1) * self.line_height
|
|
|
|
@property
|
|
def full(self) -> Length:
|
|
return Length(self.before + self.line_height * self.line_spacing * self.lines + self.after)
|
|
|
|
|
|
class ParagraphSizer:
|
|
def __init__(self, paragraph: Paragraph, previous_paragraph: Paragraph | None, max_width: Length):
|
|
self.previous_paragraph = previous_paragraph
|
|
self.max_width = max_width
|
|
self.paragraph = paragraph
|
|
|
|
self.same_style_as_previous = (paragraph.style == previous_paragraph.style) if previous_paragraph else False
|
|
|
|
@cached_property
|
|
def _default_style(self):
|
|
|
|
default_style_element = type("DefaultStyle", (), {})
|
|
default_style_element.rPr = \
|
|
self.paragraph.part.document.styles.element.xpath(
|
|
'w:docDefaults/w:rPrDefault/w:rPr')[0]
|
|
default_style_element.pPr = \
|
|
self.paragraph.part.document.styles.element.xpath(
|
|
'w:docDefaults/w:pPrDefault/w:pPr')[0]
|
|
default_style = _ParagraphStyle(default_style_element)
|
|
return default_style
|
|
|
|
@cached_property
|
|
def _styles(self) -> list[_ParagraphStyle]:
|
|
styles = [self.paragraph.style]
|
|
while styles[-1].base_style:
|
|
styles.append(styles[-1].base_style)
|
|
styles.append(self._default_style)
|
|
return styles
|
|
|
|
@cached_property
|
|
def _is_contextual_spacing(self) -> bool:
|
|
contextual_spacing = False
|
|
pPrs = [self.paragraph.paragraph_format._element.pPr] + \
|
|
[style._element.pPr for style in self._styles]
|
|
for pPr in pPrs:
|
|
if pPr.xpath("./w:contextualSpacing"):
|
|
contextual_spacing = True
|
|
break
|
|
return contextual_spacing
|
|
|
|
def count_lines(self, runs: list[Run], max_width: Length, docx_font: DocxFont, first_line_indent: Length,
|
|
is_mono: bool = False):
|
|
lines = 1
|
|
line_width = first_line_indent
|
|
|
|
space_width = Font(docx_font.name, docx_font.bold, docx_font.italic, docx_font.size.pt).get_text_width(" ")
|
|
if not is_mono:
|
|
space_width *= 0.81
|
|
|
|
word_part = ""
|
|
word_parts_widths = [0]
|
|
spaces = 0
|
|
for i, run in enumerate(runs):
|
|
if word_part:
|
|
word_part = ""
|
|
word_parts_widths.append(0)
|
|
|
|
run_docx_font = _merge_objects(
|
|
docx_font,
|
|
run.font
|
|
)
|
|
font = Font(run_docx_font.name, run_docx_font.bold, run_docx_font.italic, run_docx_font.size.pt)
|
|
|
|
run_text = run.text
|
|
if run_text == "" and run._element.xpath("w:noBreakHyphen"):
|
|
run_text = "-"
|
|
if i == len(runs) - 1:
|
|
run_text += " " # add space to the end of the last run, so it adds the last word
|
|
|
|
for c in run_text:
|
|
if c == " ":
|
|
if any(word_parts_widths):
|
|
width = spaces*space_width + sum(word_parts_widths)
|
|
if width <= max_width - line_width:
|
|
line_width += width
|
|
elif width > max_width - first_line_indent:
|
|
if lines == 1 and line_width == first_line_indent and not spaces:
|
|
lines += ceil((width - (max_width - first_line_indent)) / max_width)
|
|
line_width = (width - (max_width - first_line_indent)) % max_width
|
|
else:
|
|
lines += ceil(width / max_width)
|
|
line_width = width % max_width
|
|
else:
|
|
lines += 1
|
|
line_width = sum(word_parts_widths)
|
|
|
|
word_part = ""
|
|
word_parts_widths = [0]
|
|
spaces = 1
|
|
else:
|
|
spaces += 1
|
|
else:
|
|
word_part += c
|
|
word_parts_widths[-1] = font.get_text_width(word_part)
|
|
|
|
return int(lines)
|
|
|
|
def calculate_height(self) -> ParagraphSizerResult:
|
|
max_width = self.max_width
|
|
|
|
|
|
docx_font: DocxFont = _merge_objects(
|
|
*[style.font for style in self._styles[::-1] if style.font],
|
|
self.paragraph.style.font)
|
|
|
|
paragraph_format: ParagraphFormat = _merge_objects(
|
|
*[style.paragraph_format for style in self._styles[::-1]
|
|
if style.paragraph_format],
|
|
self.paragraph.paragraph_format
|
|
)
|
|
|
|
max_width -= (paragraph_format.left_indent or 0) + \
|
|
(paragraph_format.right_indent or 0)
|
|
|
|
font = Font(docx_font.name, docx_font.bold, docx_font.italic, docx_font.size.pt)
|
|
|
|
# here self.paragraph.runs is not used because
|
|
# it does not always return all runs (e.g. if they are inside hyperlink)
|
|
runs = []
|
|
for element in self.paragraph._element.getiterator():
|
|
if isinstance(element, CT_R):
|
|
runs.append(Run(element, self.paragraph))
|
|
|
|
lines = self.count_lines(runs, max_width, docx_font, paragraph_format.first_line_indent or 0,
|
|
font.is_mono)
|
|
|
|
previous_paragraph_format: ParagraphFormat = None
|
|
if self.previous_paragraph:
|
|
previous_paragraph_styles = [self.previous_paragraph.style]
|
|
while previous_paragraph_styles[-1].base_style:
|
|
previous_paragraph_styles.append(
|
|
previous_paragraph_styles[-1].base_style
|
|
)
|
|
previous_paragraph_styles.append(self._default_style)
|
|
previous_paragraph_format = _merge_objects(
|
|
*[style.paragraph_format for style in previous_paragraph_styles[::-1]
|
|
if style.paragraph_format],
|
|
self.previous_paragraph.paragraph_format
|
|
)
|
|
|
|
if self._is_contextual_spacing and self.same_style_as_previous:
|
|
before = (previous_paragraph_format.space_after or 0)
|
|
else:
|
|
before = (paragraph_format.space_before or 0)
|
|
if previous_paragraph_format:
|
|
before = max(0, before - (previous_paragraph_format.space_after or 0))
|
|
|
|
after = (paragraph_format.space_after or 0)
|
|
|
|
line_height = font.get_line_height()
|
|
line_spacing = paragraph_format.line_spacing
|
|
if paragraph_format.line_spacing_rule == WD_LINE_SPACING.EXACTLY:
|
|
line_spacing /= line_height
|
|
# raise NotImplementedError("Line spacing rule AT_LEAST is not supported")
|
|
elif paragraph_format.line_spacing_rule == WD_LINE_SPACING.AT_LEAST:
|
|
raise NotImplementedError("Line spacing rule AT_LEAST is not supported")
|
|
|
|
return ParagraphSizerResult(before, lines, line_height, line_spacing, after)
|