Implement Paperjet updates
This commit is contained in:
parent
5e19b78259
commit
4569dea864
19 changed files with 1622 additions and 151 deletions
|
|
@ -46,6 +46,7 @@ class DrawProps(BaseModel):
|
|||
svgPath: str | None = None
|
||||
strokeColor: str = "#000000"
|
||||
strokeWidth: float = 2
|
||||
strokeWidthUnit: Literal["pdf", "screen"] | None = None
|
||||
opacity: float = 1.0
|
||||
|
||||
|
||||
|
|
@ -65,6 +66,7 @@ class SignatureTypeProps(BaseModel):
|
|||
text: str
|
||||
fontFamily: str
|
||||
color: str = "#000000"
|
||||
fontSize: float | None = None
|
||||
|
||||
|
||||
class SignatureAnnotation(AnnotationBase):
|
||||
|
|
@ -98,6 +100,7 @@ class ShapeProps(BaseModel):
|
|||
strokeColor: str = "#000000"
|
||||
fillColor: str = "transparent"
|
||||
strokeWidth: float = 2
|
||||
strokeWidthUnit: Literal["pdf", "screen"] | None = None
|
||||
start: tuple[float, float] | None = None
|
||||
end: tuple[float, float] | None = None
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
|
|
@ -88,17 +89,148 @@ _SIGNATURE_FONT_FILES = {
|
|||
}
|
||||
|
||||
|
||||
_WEB_FONT_FILES: dict[tuple[str, bool, bool], tuple[str, ...]] = {
|
||||
("outfit", False, False): ("outfit-400.ttf",),
|
||||
("outfit", True, False): ("outfit-700.ttf", "outfit-400.ttf"),
|
||||
("plus jakarta sans", False, False): ("plus-jakarta-sans-400.ttf",),
|
||||
("plus jakarta sans", True, False): ("plus-jakarta-sans-700.ttf", "plus-jakarta-sans-400.ttf"),
|
||||
("plus jakarta sans", False, True): (
|
||||
"plus-jakarta-sans-400-italic.ttf",
|
||||
"plus-jakarta-sans-400.ttf",
|
||||
),
|
||||
("plus jakarta sans", True, True): (
|
||||
"plus-jakarta-sans-700-italic.ttf",
|
||||
"plus-jakarta-sans-700.ttf",
|
||||
),
|
||||
("great vibes", False, False): ("great-vibes-400.ttf", "GreatVibes-Regular.ttf"),
|
||||
("allura", False, False): ("allura-400.ttf", "allura.ttf"),
|
||||
("sacramento", False, False): ("sacramento-400.ttf", "sacramento.ttf"),
|
||||
("dancing script", False, False): ("dancing-script-400.ttf", "dancing-script.ttf"),
|
||||
("caveat", False, False): ("caveat-400.ttf", "caveat.ttf"),
|
||||
}
|
||||
|
||||
|
||||
_SYSTEM_FONT_FAMILIES = {
|
||||
"arial": "LiberationSans",
|
||||
"helvetica": "LiberationSans",
|
||||
"liberation sans": "LiberationSans",
|
||||
"courier": "LiberationMono",
|
||||
"courier new": "LiberationMono",
|
||||
"liberation mono": "LiberationMono",
|
||||
"mono": "LiberationMono",
|
||||
"serif": "LiberationSerif",
|
||||
"times": "LiberationSerif",
|
||||
"times new roman": "LiberationSerif",
|
||||
"liberation serif": "LiberationSerif",
|
||||
}
|
||||
|
||||
|
||||
def _is_bold(value: Any) -> bool:
|
||||
return value is True or str(value).strip().lower() in {"bold", "700", "800", "900"}
|
||||
|
||||
|
||||
def _is_italic(value: Any) -> bool:
|
||||
return value is True or str(value).strip().lower() in {"italic", "oblique"}
|
||||
|
||||
|
||||
def _variant_filename(stem: str, bold: bool, italic: bool) -> str:
|
||||
if bold and italic:
|
||||
suffix = "-BoldItalic"
|
||||
elif bold:
|
||||
suffix = "-Bold"
|
||||
elif italic:
|
||||
suffix = "-Italic"
|
||||
else:
|
||||
suffix = "-Regular"
|
||||
return f"{stem}{suffix}.ttf"
|
||||
|
||||
|
||||
def _font_roots() -> tuple[Path, ...]:
|
||||
source_root = Path(__file__).parents[2] / "assets" / "fonts"
|
||||
configured_root = os.environ.get("PAPERJET_FONT_DIR")
|
||||
roots = [Path(configured_root)] if configured_root else []
|
||||
roots.extend(
|
||||
[
|
||||
source_root / "web",
|
||||
source_root,
|
||||
Path("/usr/share/fonts/truetype/liberation"),
|
||||
Path("/usr/local/share/fonts"),
|
||||
]
|
||||
)
|
||||
return tuple(dict.fromkeys(roots))
|
||||
|
||||
|
||||
def _font_file(family: Any, bold: Any = False, italic: Any = False) -> Path | None:
|
||||
normalized = str(family or "").strip().lower()
|
||||
bold_flag = _is_bold(bold)
|
||||
italic_flag = _is_italic(italic)
|
||||
key = (normalized, bold_flag, italic_flag)
|
||||
filenames = _WEB_FONT_FILES.get(key)
|
||||
if filenames is None and italic_flag:
|
||||
filenames = _WEB_FONT_FILES.get((key[0], key[1], False))
|
||||
if filenames is None:
|
||||
system_stem = _SYSTEM_FONT_FAMILIES.get(normalized)
|
||||
filenames = (_variant_filename(system_stem, bold_flag, italic_flag),) if system_stem else ()
|
||||
for root in _font_roots():
|
||||
for filename in filenames:
|
||||
path = root / filename
|
||||
if path.is_file():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _font_choice(family: Any, bold: Any = False, italic: Any = False) -> tuple[str, Path | None]:
|
||||
font_file = _font_file(family, bold, italic)
|
||||
if font_file is not None:
|
||||
return f"paperjet-{font_file.stem.lower()}", font_file
|
||||
return _font_name(family, _is_bold(bold), _is_italic(italic)), None
|
||||
|
||||
|
||||
def _needs_synthetic_italic(family: Any, bold: Any = False, italic: Any = False) -> bool:
|
||||
if not _is_italic(italic):
|
||||
return False
|
||||
normalized = str(family or "").strip().lower()
|
||||
if not any(key[0] == normalized for key in _WEB_FONT_FILES):
|
||||
return False
|
||||
return (normalized, _is_bold(bold), True) not in _WEB_FONT_FILES
|
||||
|
||||
|
||||
def _synthetic_italic_morph(origin: pymupdf.Point) -> tuple[pymupdf.Point, pymupdf.Matrix]:
|
||||
# Match the browser's synthesized oblique for families without an italic face.
|
||||
shear = math.tan(math.radians(14))
|
||||
return origin, pymupdf.Matrix(1, 0, shear, 1, 0, 0)
|
||||
|
||||
|
||||
def _signature_font_file(family: Any) -> Path | None:
|
||||
filename = _SIGNATURE_FONT_FILES.get(str(family or "").strip().lower())
|
||||
if not filename:
|
||||
normalized = str(family or "").strip().lower()
|
||||
for filename in _WEB_FONT_FILES.get((normalized, False, False), ()):
|
||||
for root in _font_roots():
|
||||
path = root / filename
|
||||
if path.is_file():
|
||||
return path
|
||||
legacy_filename = _SIGNATURE_FONT_FILES.get(normalized)
|
||||
if not legacy_filename:
|
||||
return None
|
||||
path = Path(__file__).parents[2] / "assets" / "fonts" / filename
|
||||
path = Path(__file__).parents[2] / "assets" / "fonts" / legacy_filename
|
||||
return path if path.is_file() else None
|
||||
|
||||
|
||||
def _signature_font_size(
|
||||
rect: pymupdf.Rect, text: str, font_file: Path | None, font_name: str
|
||||
rect: pymupdf.Rect,
|
||||
text: str,
|
||||
font_file: Path | None,
|
||||
font_name: str,
|
||||
requested_size: Any = None,
|
||||
) -> float:
|
||||
if requested_size is not None:
|
||||
try:
|
||||
return max(1.0, float(requested_size))
|
||||
except (TypeError, ValueError):
|
||||
return 48.0
|
||||
|
||||
# Legacy typed signatures did not persist a font size. Preserve their
|
||||
# existing sizing behavior while new records use the preview's canonical
|
||||
# size above.
|
||||
size = max(1.0, min(48.0, rect.height * 0.55))
|
||||
try:
|
||||
font = (
|
||||
|
|
@ -168,10 +300,242 @@ def _draw_polyline(
|
|||
return
|
||||
shape = page.new_shape()
|
||||
shape.draw_polyline(points)
|
||||
shape.finish(color=color, width=max(0.1, width), stroke_opacity=opacity)
|
||||
shape.finish(
|
||||
color=color,
|
||||
width=max(0.1, width),
|
||||
lineCap=1,
|
||||
lineJoin=1,
|
||||
closePath=False,
|
||||
stroke_opacity=opacity,
|
||||
)
|
||||
shape.commit(overlay=True)
|
||||
|
||||
|
||||
_SVG_PATH_TOKEN = re.compile(r"[A-Za-z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?")
|
||||
_SVG_PATH_ARITY = {"M": 2, "L": 2, "Q": 4, "C": 6}
|
||||
|
||||
|
||||
def _parse_svg_path(value: str) -> list[tuple[str, tuple[float, ...]]]:
|
||||
tokens = _SVG_PATH_TOKEN.findall(value)
|
||||
commands: list[tuple[str, tuple[float, ...]]] = []
|
||||
command: str | None = None
|
||||
relative = False
|
||||
move_pending = False
|
||||
index = 0
|
||||
current = (0.0, 0.0)
|
||||
subpath_start = current
|
||||
|
||||
while index < len(tokens):
|
||||
token = tokens[index]
|
||||
if token.isalpha():
|
||||
command = token.upper()
|
||||
relative = token.islower()
|
||||
index += 1
|
||||
if command == "Z":
|
||||
commands.append(("Z", ()))
|
||||
current = subpath_start
|
||||
command = None
|
||||
continue
|
||||
if command not in _SVG_PATH_ARITY:
|
||||
return []
|
||||
move_pending = command == "M"
|
||||
|
||||
if command is None:
|
||||
return []
|
||||
arity = _SVG_PATH_ARITY[command]
|
||||
if index + arity > len(tokens):
|
||||
return []
|
||||
if any(tokens[index + offset].isalpha() for offset in range(arity)):
|
||||
return []
|
||||
try:
|
||||
values = tuple(float(tokens[index + offset]) for offset in range(arity))
|
||||
except ValueError:
|
||||
return []
|
||||
index += arity
|
||||
|
||||
base_x, base_y = current
|
||||
if command in {"M", "L"}:
|
||||
point = (
|
||||
values[0] + (base_x if relative else 0.0),
|
||||
values[1] + (base_y if relative else 0.0),
|
||||
)
|
||||
output_command = "M" if move_pending else "L"
|
||||
commands.append((output_command, point))
|
||||
current = point
|
||||
if output_command == "M":
|
||||
subpath_start = point
|
||||
move_pending = False
|
||||
command = "L" if command == "M" else command
|
||||
elif command == "Q":
|
||||
control = (
|
||||
values[0] + (base_x if relative else 0.0),
|
||||
values[1] + (base_y if relative else 0.0),
|
||||
)
|
||||
endpoint = (
|
||||
values[2] + (base_x if relative else 0.0),
|
||||
values[3] + (base_y if relative else 0.0),
|
||||
)
|
||||
commands.append(("Q", (*control, *endpoint)))
|
||||
current = endpoint
|
||||
else:
|
||||
control_one = (
|
||||
values[0] + (base_x if relative else 0.0),
|
||||
values[1] + (base_y if relative else 0.0),
|
||||
)
|
||||
control_two = (
|
||||
values[2] + (base_x if relative else 0.0),
|
||||
values[3] + (base_y if relative else 0.0),
|
||||
)
|
||||
endpoint = (
|
||||
values[4] + (base_x if relative else 0.0),
|
||||
values[5] + (base_y if relative else 0.0),
|
||||
)
|
||||
commands.append(("C", (*control_one, *control_two, *endpoint)))
|
||||
current = endpoint
|
||||
|
||||
return commands
|
||||
|
||||
|
||||
def _draw_svg_path(
|
||||
page: pymupdf.Page,
|
||||
rect: pymupdf.Rect,
|
||||
svg_path: str,
|
||||
*,
|
||||
color: tuple[float, float, float],
|
||||
width: float,
|
||||
opacity: float,
|
||||
) -> bool:
|
||||
parsed = _parse_svg_path(svg_path)
|
||||
if not parsed:
|
||||
return False
|
||||
|
||||
segments: list[tuple[str, tuple[tuple[float, float], ...]]] = []
|
||||
samples: list[tuple[float, float]] = []
|
||||
current = (0.0, 0.0)
|
||||
subpath_start = current
|
||||
|
||||
def add_line(start: tuple[float, float], end: tuple[float, float]) -> None:
|
||||
segments.append(("L", (start, end)))
|
||||
samples.extend((start, end))
|
||||
|
||||
def add_quadratic(
|
||||
start: tuple[float, float],
|
||||
control: tuple[float, float],
|
||||
end: tuple[float, float],
|
||||
) -> None:
|
||||
segments.append(("Q", (start, control, end)))
|
||||
for step in range(21):
|
||||
t = step / 20
|
||||
inverse = 1 - t
|
||||
samples.append(
|
||||
(
|
||||
inverse * inverse * start[0]
|
||||
+ 2 * inverse * t * control[0]
|
||||
+ t * t * end[0],
|
||||
inverse * inverse * start[1]
|
||||
+ 2 * inverse * t * control[1]
|
||||
+ t * t * end[1],
|
||||
)
|
||||
)
|
||||
|
||||
def add_cubic(
|
||||
start: tuple[float, float],
|
||||
control_one: tuple[float, float],
|
||||
control_two: tuple[float, float],
|
||||
end: tuple[float, float],
|
||||
) -> None:
|
||||
segments.append(("C", (start, control_one, control_two, end)))
|
||||
for step in range(21):
|
||||
t = step / 20
|
||||
inverse = 1 - t
|
||||
samples.append(
|
||||
(
|
||||
inverse**3 * start[0]
|
||||
+ 3 * inverse**2 * t * control_one[0]
|
||||
+ 3 * inverse * t**2 * control_two[0]
|
||||
+ t**3 * end[0],
|
||||
inverse**3 * start[1]
|
||||
+ 3 * inverse**2 * t * control_one[1]
|
||||
+ 3 * inverse * t**2 * control_two[1]
|
||||
+ t**3 * end[1],
|
||||
)
|
||||
)
|
||||
|
||||
for command, values in parsed:
|
||||
if command == "M":
|
||||
current = (values[0], values[1])
|
||||
subpath_start = current
|
||||
samples.append(current)
|
||||
elif command == "L":
|
||||
endpoint = (values[0], values[1])
|
||||
add_line(current, endpoint)
|
||||
current = endpoint
|
||||
elif command == "Q":
|
||||
control = (values[0], values[1])
|
||||
endpoint = (values[2], values[3])
|
||||
add_quadratic(current, control, endpoint)
|
||||
current = endpoint
|
||||
elif command == "C":
|
||||
control_one = (values[0], values[1])
|
||||
control_two = (values[2], values[3])
|
||||
endpoint = (values[4], values[5])
|
||||
add_cubic(current, control_one, control_two, endpoint)
|
||||
current = endpoint
|
||||
elif command == "Z":
|
||||
if current != subpath_start:
|
||||
add_line(current, subpath_start)
|
||||
current = subpath_start
|
||||
|
||||
if not segments or not samples:
|
||||
return False
|
||||
min_x = min(point[0] for point in samples)
|
||||
max_x = max(point[0] for point in samples)
|
||||
min_y = min(point[1] for point in samples)
|
||||
max_y = max(point[1] for point in samples)
|
||||
source_width = max(max_x - min_x, 1e-6)
|
||||
source_height = max(max_y - min_y, 1e-6)
|
||||
|
||||
def map_point(point: tuple[float, float]) -> pymupdf.Point:
|
||||
return pymupdf.Point(
|
||||
rect.x0 + (point[0] - min_x) / source_width * rect.width,
|
||||
rect.y0 + (point[1] - min_y) / source_height * rect.height,
|
||||
)
|
||||
|
||||
shape = page.new_shape()
|
||||
for command, points in segments:
|
||||
mapped = tuple(map_point(point) for point in points)
|
||||
if command == "L":
|
||||
shape.draw_line(mapped[0], mapped[1])
|
||||
elif command == "Q":
|
||||
curve_start, curve_control, curve_end = mapped
|
||||
mapped_control_one = pymupdf.Point(
|
||||
curve_start.x + (curve_control.x - curve_start.x) * 2 / 3,
|
||||
curve_start.y + (curve_control.y - curve_start.y) * 2 / 3,
|
||||
)
|
||||
mapped_control_two = pymupdf.Point(
|
||||
curve_end.x + (curve_control.x - curve_end.x) * 2 / 3,
|
||||
curve_end.y + (curve_control.y - curve_end.y) * 2 / 3,
|
||||
)
|
||||
shape.draw_bezier(
|
||||
curve_start,
|
||||
mapped_control_one,
|
||||
mapped_control_two,
|
||||
curve_end,
|
||||
)
|
||||
else:
|
||||
shape.draw_bezier(*mapped)
|
||||
shape.finish(
|
||||
color=color,
|
||||
width=max(0.1, width),
|
||||
lineCap=1,
|
||||
lineJoin=1,
|
||||
closePath=False,
|
||||
stroke_opacity=opacity,
|
||||
)
|
||||
shape.commit(overlay=True)
|
||||
return True
|
||||
|
||||
|
||||
def _style_at(styles: Any, line_number: int, character_number: int) -> Mapping[str, Any]:
|
||||
if not isinstance(styles, Mapping):
|
||||
return {}
|
||||
|
|
@ -182,6 +546,49 @@ def _style_at(styles: Any, line_number: int, character_number: int) -> Mapping[s
|
|||
return character_style if isinstance(character_style, Mapping) else {}
|
||||
|
||||
|
||||
def _insert_text_lines(
|
||||
page: pymupdf.Page,
|
||||
rect: pymupdf.Rect,
|
||||
text: str,
|
||||
*,
|
||||
fontsize: float,
|
||||
font_name: str,
|
||||
font_file: Path | None = None,
|
||||
color: tuple[float, float, float],
|
||||
align: int = 0,
|
||||
line_height: float = 1.2,
|
||||
synthetic_italic: bool = False,
|
||||
) -> None:
|
||||
"""Insert text without requiring it to fit inside the saved rectangle.
|
||||
|
||||
Fabric keeps text visible even when a manually sized textbox is shorter
|
||||
than the font metrics. PyMuPDF's textbox helper instead returns a negative
|
||||
spare-height value and may insert nothing, so this fallback preserves the
|
||||
preview's overflow behavior.
|
||||
"""
|
||||
font = pymupdf.Font(fontfile=str(font_file)) if font_file else pymupdf.Font(fontname=font_name)
|
||||
for line_number, line in enumerate(text.split("\n")):
|
||||
line_width = float(font.text_length(line, fontsize=fontsize))
|
||||
x = rect.x0
|
||||
if align == 1:
|
||||
x += max(0.0, (rect.width - line_width) / 2)
|
||||
elif align == 2:
|
||||
x += max(0.0, rect.width - line_width)
|
||||
|
||||
options: dict[str, Any] = {
|
||||
"fontsize": fontsize,
|
||||
"fontname": font_name,
|
||||
"color": color,
|
||||
"overlay": True,
|
||||
}
|
||||
if font_file:
|
||||
options["fontfile"] = str(font_file)
|
||||
origin = pymupdf.Point(x, rect.y0 + fontsize + line_number * fontsize * line_height)
|
||||
if synthetic_italic:
|
||||
options["morph"] = _synthetic_italic_morph(origin)
|
||||
page.insert_text(origin, line, **options)
|
||||
|
||||
|
||||
def _render_styled_text(
|
||||
page: pymupdf.Page,
|
||||
rect: pymupdf.Rect,
|
||||
|
|
@ -189,31 +596,62 @@ def _render_styled_text(
|
|||
props: Mapping[str, Any],
|
||||
styles: Any,
|
||||
) -> bool:
|
||||
if not isinstance(styles, Mapping) or not styles:
|
||||
return False
|
||||
if not isinstance(styles, Mapping):
|
||||
styles = {}
|
||||
|
||||
base_size = max(1.0, float(props.get("fontSize", 14)))
|
||||
line_height = max(0.5, float(props.get("lineHeight", 1.2)))
|
||||
lines = text.split("\n")
|
||||
y = rect.y0
|
||||
for line_number, line in enumerate(lines):
|
||||
measured: list[tuple[str, float, str, tuple[float, float, float], float, str | None]] = []
|
||||
measured: list[
|
||||
tuple[
|
||||
str,
|
||||
float,
|
||||
str,
|
||||
Path | None,
|
||||
tuple[float, float, float],
|
||||
float,
|
||||
str | None,
|
||||
bool,
|
||||
]
|
||||
] = []
|
||||
for character_number, character in enumerate(line):
|
||||
style = _style_at(styles, line_number, character_number)
|
||||
font_size = max(1.0, float(style.get("fontSize", base_size)))
|
||||
font_family = style.get("fontFamily", props.get("fontFamily"))
|
||||
font_name = _font_name(
|
||||
font_name, font_file = _font_choice(
|
||||
font_family,
|
||||
style.get("fontWeight") == "bold",
|
||||
style.get("fontStyle") == "italic",
|
||||
_is_bold(style.get("fontWeight", "bold" if props.get("bold") else None)),
|
||||
_is_italic(style.get("fontStyle", "italic" if props.get("italic") else None)),
|
||||
)
|
||||
synthetic_italic = _needs_synthetic_italic(
|
||||
font_family,
|
||||
style.get("fontWeight", "bold" if props.get("bold") else None),
|
||||
style.get("fontStyle", "italic" if props.get("italic") else None),
|
||||
)
|
||||
color = _color(style.get("fill", props.get("color")))
|
||||
font = pymupdf.Font(fontname=font_name)
|
||||
font = (
|
||||
pymupdf.Font(fontfile=str(font_file))
|
||||
if font_file
|
||||
else pymupdf.Font(fontname=font_name)
|
||||
)
|
||||
width = float(font.text_length(character, fontsize=font_size))
|
||||
background = style.get("textBackgroundColor", props.get("highlightColor"))
|
||||
measured.append((character, font_size, font_name, color, width, background))
|
||||
measured.append(
|
||||
(
|
||||
character,
|
||||
font_size,
|
||||
font_name,
|
||||
font_file,
|
||||
color,
|
||||
width,
|
||||
background,
|
||||
synthetic_italic,
|
||||
)
|
||||
)
|
||||
|
||||
total_width = sum(item[4] for item in measured)
|
||||
total_width = sum(item[5] for item in measured)
|
||||
align = str(props.get("align", "left"))
|
||||
x = rect.x0
|
||||
if align == "center":
|
||||
|
|
@ -222,27 +660,43 @@ def _render_styled_text(
|
|||
x += max(0.0, rect.width - total_width)
|
||||
|
||||
max_size = max((item[1] for item in measured), default=base_size)
|
||||
for character, font_size, font_name, color, width, background in measured:
|
||||
line_box_height = max_size * line_height
|
||||
background_top = y + max(0.0, (line_box_height - max_size) / 2)
|
||||
background_height = max_size * 1.15
|
||||
for (
|
||||
character,
|
||||
font_size,
|
||||
font_name,
|
||||
font_file,
|
||||
color,
|
||||
width,
|
||||
background,
|
||||
synthetic_italic,
|
||||
) in measured:
|
||||
if (
|
||||
isinstance(background, str)
|
||||
and background.lower() not in {"", "transparent", "none"}
|
||||
):
|
||||
page.draw_rect(
|
||||
pymupdf.Rect(x, y, x + width, y + max_size * 1.15),
|
||||
pymupdf.Rect(x, background_top, x + width, background_top + background_height),
|
||||
color=None,
|
||||
fill=_color(background),
|
||||
fill_opacity=0.3,
|
||||
overlay=True,
|
||||
)
|
||||
if character != " ":
|
||||
page.insert_text(
|
||||
pymupdf.Point(x, y + font_size),
|
||||
character,
|
||||
fontsize=font_size,
|
||||
fontname=font_name,
|
||||
color=color,
|
||||
overlay=True,
|
||||
)
|
||||
options: dict[str, Any] = {
|
||||
"fontsize": font_size,
|
||||
"fontname": font_name,
|
||||
"color": color,
|
||||
"overlay": True,
|
||||
}
|
||||
if font_file:
|
||||
options["fontfile"] = str(font_file)
|
||||
origin = pymupdf.Point(x, y + font_size)
|
||||
if synthetic_italic:
|
||||
options["morph"] = _synthetic_italic_morph(origin)
|
||||
page.insert_text(origin, character, **options)
|
||||
x += width
|
||||
y += max_size * line_height
|
||||
|
||||
|
|
@ -257,21 +711,50 @@ def render_text(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -
|
|||
text = str(props.get("text", ""))
|
||||
if not text:
|
||||
return
|
||||
styles = props.get("styles")
|
||||
highlight = props.get("highlightColor")
|
||||
if isinstance(highlight, str) and highlight and highlight.lower() != "transparent":
|
||||
page.draw_rect(rect, color=None, fill=_color(highlight), fill_opacity=0.3, overlay=True)
|
||||
if _render_styled_text(page, rect, text, props, props.get("styles")):
|
||||
has_highlight = (
|
||||
isinstance(highlight, str)
|
||||
and highlight.strip().lower() not in {"", "transparent", "none"}
|
||||
)
|
||||
if has_highlight or (isinstance(styles, Mapping) and bool(styles)):
|
||||
_render_styled_text(page, rect, text, props, styles)
|
||||
return
|
||||
align = {"left": 0, "center": 1, "right": 2}.get(str(props.get("align", "left")), 0)
|
||||
page.insert_textbox(
|
||||
rect,
|
||||
text,
|
||||
fontsize=max(1.0, float(props.get("fontSize", 14))),
|
||||
fontname=_font_name(props.get("fontFamily"), props.get("bold"), props.get("italic")),
|
||||
color=_color(props.get("color")),
|
||||
align=align,
|
||||
overlay=True,
|
||||
font_size = max(1.0, float(props.get("fontSize", 14)))
|
||||
font_name, font_file = _font_choice(
|
||||
props.get("fontFamily"), props.get("bold"), props.get("italic")
|
||||
)
|
||||
synthetic_italic = _needs_synthetic_italic(
|
||||
props.get("fontFamily"), props.get("bold"), props.get("italic")
|
||||
)
|
||||
options: dict[str, Any] = {
|
||||
"fontsize": font_size,
|
||||
"fontname": font_name,
|
||||
"color": _color(props.get("color")),
|
||||
"align": align,
|
||||
"overlay": True,
|
||||
}
|
||||
if font_file:
|
||||
options["fontfile"] = str(font_file)
|
||||
if synthetic_italic:
|
||||
options["morph"] = _synthetic_italic_morph(
|
||||
pymupdf.Point(rect.x0, rect.y0 + font_size)
|
||||
)
|
||||
result = page.insert_textbox(rect, text, **options)
|
||||
if result < 0:
|
||||
_insert_text_lines(
|
||||
page,
|
||||
rect,
|
||||
text,
|
||||
fontsize=font_size,
|
||||
font_name=font_name,
|
||||
font_file=font_file,
|
||||
color=_color(props.get("color")),
|
||||
align=align,
|
||||
line_height=max(0.5, float(props.get("lineHeight", 1.2))),
|
||||
synthetic_italic=synthetic_italic,
|
||||
)
|
||||
|
||||
|
||||
def render_draw(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -> None:
|
||||
|
|
@ -279,6 +762,16 @@ def render_draw(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -
|
|||
if rect is None:
|
||||
return
|
||||
props = _props(annotation)
|
||||
svg_path = props.get("svgPath")
|
||||
if isinstance(svg_path, str) and _draw_svg_path(
|
||||
page,
|
||||
rect,
|
||||
svg_path,
|
||||
color=_color(props.get("strokeColor")),
|
||||
width=float(props.get("strokeWidth", 2)),
|
||||
opacity=_opacity(props.get("opacity")),
|
||||
):
|
||||
return
|
||||
_draw_polyline(
|
||||
page,
|
||||
_points_from_props(annotation, rect),
|
||||
|
|
@ -321,20 +814,41 @@ def render_signature(page: pymupdf.Page, annotation: Annotation, assets_dir: Pat
|
|||
if text:
|
||||
font_file = _signature_font_file(props.get("fontFamily"))
|
||||
font_name = _font_name(props.get("fontFamily"), False, False)
|
||||
font_size = _signature_font_size(
|
||||
rect,
|
||||
text,
|
||||
font_file,
|
||||
font_name,
|
||||
props.get("fontSize"),
|
||||
)
|
||||
font = (
|
||||
pymupdf.Font(fontfile=str(font_file))
|
||||
if font_file
|
||||
else pymupdf.Font(fontname=font_name)
|
||||
)
|
||||
options: dict[str, Any] = {
|
||||
# Script fonts have a taller ascender than the built-in PDF fonts;
|
||||
# leave room inside the editor's saved bounding rectangle so a
|
||||
# valid signature is never silently dropped for not fitting.
|
||||
"fontsize": _signature_font_size(rect, text, font_file, font_name),
|
||||
"fontsize": font_size,
|
||||
"color": _color(props.get("color")),
|
||||
"overlay": True,
|
||||
}
|
||||
# The font carries its own negative script-glyph bearings, so the PDF
|
||||
# insertion origin is the Fabric text box edge. Fit the run to the
|
||||
# canonical width to account for browser/PDF shaping differences and
|
||||
# for intentional non-uniform resizing.
|
||||
text_x = rect.x0
|
||||
text_baseline = rect.y0 + font.ascender * font_size
|
||||
text_width = float(font.text_length(text, fontsize=font_size))
|
||||
if text_width > 0 and rect.width > 0:
|
||||
options["morph"] = (
|
||||
pymupdf.Point(text_x, text_baseline),
|
||||
pymupdf.Matrix(rect.width / text_width, 0, 0, 1, 0, 0),
|
||||
)
|
||||
if font_file:
|
||||
options["fontname"] = f"paperjet-{font_file.stem.lower()}"
|
||||
options["fontfile"] = str(font_file)
|
||||
else:
|
||||
options["fontname"] = font_name
|
||||
page.insert_textbox(rect, text, **options)
|
||||
page.insert_text(pymupdf.Point(text_x, text_baseline), text, **options)
|
||||
|
||||
|
||||
def render_highlight(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -> None:
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@
|
|||
import json
|
||||
|
||||
import pymupdf
|
||||
import pytest
|
||||
|
||||
from app.services.export.renderer import export_annotations
|
||||
from app.services.export.renderer import _needs_synthetic_italic, export_annotations
|
||||
|
||||
|
||||
def _source_pdf(path) -> None:
|
||||
|
|
@ -62,6 +63,7 @@ def test_export_preserves_rotation_and_flattens_supported_annotations(tmp_path)
|
|||
"text": "Ava",
|
||||
"fontFamily": "Great Vibes",
|
||||
"color": "#000000",
|
||||
"fontSize": 48,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -92,6 +94,21 @@ def test_export_preserves_rotation_and_flattens_supported_annotations(tmp_path)
|
|||
assert "Exported text" in page.get_text("text")
|
||||
assert "Ava" in page.get_text("text")
|
||||
|
||||
signature_spans = [
|
||||
span
|
||||
for block in page.get_text("dict")["blocks"]
|
||||
if "lines" in block
|
||||
for line in block["lines"]
|
||||
for span in line["spans"]
|
||||
if span["text"] == "Ava"
|
||||
]
|
||||
assert signature_spans
|
||||
signature_span = signature_spans[0]
|
||||
assert signature_span["bbox"][2] - signature_span["bbox"][0] == pytest.approx(160)
|
||||
assert signature_span["bbox"][3] - signature_span["bbox"][1] == pytest.approx(
|
||||
(signature_span["ascender"] - signature_span["descender"]) * 48
|
||||
)
|
||||
|
||||
# Normalize only for inspection: the exported PDF still retains the
|
||||
# original page rotation above.
|
||||
page.set_rotation(0)
|
||||
|
|
@ -102,6 +119,409 @@ def test_export_preserves_rotation_and_flattens_supported_annotations(tmp_path)
|
|||
assert len(page.get_drawings()) >= 3
|
||||
|
||||
|
||||
def test_export_renders_text_when_saved_box_is_short(tmp_path) -> None:
|
||||
source = tmp_path / "source.pdf"
|
||||
document = pymupdf.open()
|
||||
document.new_page(width=240, height=320)
|
||||
document.save(source)
|
||||
document.close()
|
||||
|
||||
annotations = [
|
||||
{
|
||||
"id": "short-text",
|
||||
"page": 0,
|
||||
"type": "text",
|
||||
"rect": {"x": 20, "y": 20, "width": 120, "height": 12},
|
||||
"props": {
|
||||
"text": "Small text",
|
||||
"fontFamily": "Liberation Sans",
|
||||
"fontSize": 14,
|
||||
"color": "#000000",
|
||||
"align": "left",
|
||||
"lineHeight": 1.2,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "styled-multiline",
|
||||
"page": 0,
|
||||
"type": "text",
|
||||
"rect": {"x": 20, "y": 60, "width": 120, "height": 18},
|
||||
"props": {
|
||||
"text": "Styled\ntext",
|
||||
"fontFamily": "Liberation Sans",
|
||||
"fontSize": 14,
|
||||
"color": "#000000",
|
||||
"align": "left",
|
||||
"lineHeight": 1.2,
|
||||
"styles": {
|
||||
"0": {"0": {"fill": "#ff0000", "fontWeight": "bold"}},
|
||||
"1": {"0": {"fill": "#0000ff", "fontStyle": "italic"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
exported = export_annotations(source, annotations, tmp_path / "assets")
|
||||
|
||||
with pymupdf.open(stream=exported, filetype="pdf") as document:
|
||||
text = document[0].get_text("text")
|
||||
assert "Small text" in text
|
||||
assert "Styled" in text
|
||||
assert "text" in text
|
||||
|
||||
|
||||
def test_export_uses_the_selected_text_font(tmp_path) -> None:
|
||||
source = tmp_path / "source.pdf"
|
||||
document = pymupdf.open()
|
||||
document.new_page(width=300, height=140)
|
||||
document.save(source)
|
||||
document.close()
|
||||
|
||||
annotations = [
|
||||
{
|
||||
"id": "outfit-text",
|
||||
"page": 0,
|
||||
"type": "text",
|
||||
"rect": {"x": 20, "y": 20, "width": 220, "height": 40},
|
||||
"props": {
|
||||
"text": "Outfit text",
|
||||
"fontFamily": "Outfit",
|
||||
"fontSize": 24,
|
||||
"color": "#000000",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
exported = export_annotations(source, annotations, tmp_path / "assets")
|
||||
|
||||
with pymupdf.open(stream=exported, filetype="pdf") as document:
|
||||
spans = [
|
||||
span
|
||||
for block in document[0].get_text("dict")["blocks"]
|
||||
if "lines" in block
|
||||
for line in block["lines"]
|
||||
for span in line["spans"]
|
||||
if span["text"] == "Outfit text"
|
||||
]
|
||||
assert spans
|
||||
assert "outfit" in spans[0]["font"].lower()
|
||||
|
||||
|
||||
def test_export_uses_an_embeddable_serif_font_for_times_new_roman(tmp_path) -> None:
|
||||
source = tmp_path / "source.pdf"
|
||||
document = pymupdf.open()
|
||||
document.new_page(width=300, height=120)
|
||||
document.save(source)
|
||||
document.close()
|
||||
|
||||
annotations = [
|
||||
{
|
||||
"id": "times-text",
|
||||
"page": 0,
|
||||
"type": "text",
|
||||
"rect": {"x": 20, "y": 20, "width": 240, "height": 40},
|
||||
"props": {
|
||||
"text": "Times New Roman",
|
||||
"fontFamily": "Times New Roman",
|
||||
"fontSize": 20,
|
||||
"color": "#000000",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
exported = export_annotations(source, annotations, tmp_path / "assets")
|
||||
|
||||
with pymupdf.open(stream=exported, filetype="pdf") as document:
|
||||
spans = [
|
||||
span
|
||||
for block in document[0].get_text("dict")["blocks"]
|
||||
if "lines" in block
|
||||
for line in block["lines"]
|
||||
for span in line["spans"]
|
||||
if span["text"] == "Times New Roman"
|
||||
]
|
||||
assert spans
|
||||
assert "liberationserif" in spans[0]["font"].lower()
|
||||
|
||||
|
||||
def test_export_highlight_tracks_styled_text_instead_of_the_saved_box(tmp_path) -> None:
|
||||
source = tmp_path / "source.pdf"
|
||||
document = pymupdf.open()
|
||||
document.new_page(width=320, height=140)
|
||||
document.save(source)
|
||||
document.close()
|
||||
|
||||
annotation_rect = pymupdf.Rect(20, 20, 260, 50)
|
||||
annotations = [
|
||||
{
|
||||
"id": "styled-text",
|
||||
"page": 0,
|
||||
"type": "text",
|
||||
"rect": {
|
||||
"x": annotation_rect.x0,
|
||||
"y": annotation_rect.y0,
|
||||
"width": annotation_rect.width,
|
||||
"height": annotation_rect.height,
|
||||
},
|
||||
"props": {
|
||||
"text": "Highlighted text",
|
||||
"fontFamily": "Liberation Sans",
|
||||
"fontSize": 18,
|
||||
"color": "#000000",
|
||||
"highlightColor": "#ffff00",
|
||||
"styles": {"0": {"0": {"fill": "#ff0000"}}},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
exported = export_annotations(source, annotations, tmp_path / "assets")
|
||||
|
||||
with pymupdf.open(stream=exported, filetype="pdf") as document:
|
||||
fill_drawings = [drawing for drawing in document[0].get_drawings() if drawing["fill"]]
|
||||
assert fill_drawings
|
||||
assert all(drawing["rect"] != annotation_rect for drawing in fill_drawings)
|
||||
assert all(drawing["rect"].width < annotation_rect.width for drawing in fill_drawings)
|
||||
|
||||
|
||||
def test_export_preserves_smooth_freehand_strokes(tmp_path) -> None:
|
||||
source = tmp_path / "source.pdf"
|
||||
document = pymupdf.open()
|
||||
document.new_page(width=300, height=180)
|
||||
document.save(source)
|
||||
document.close()
|
||||
|
||||
annotations = [
|
||||
{
|
||||
"id": "smooth-draw",
|
||||
"page": 0,
|
||||
"type": "draw",
|
||||
"rect": {"x": 20, "y": 20, "width": 180, "height": 100},
|
||||
"props": {
|
||||
"svgPath": "M 0 0 Q 45 100 90 0 Q 135 -100 180 0",
|
||||
"strokeColor": "#000000",
|
||||
"strokeWidth": 2,
|
||||
"strokeWidthUnit": "pdf",
|
||||
"opacity": 1,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
exported = export_annotations(source, annotations, tmp_path / "assets")
|
||||
|
||||
with pymupdf.open(stream=exported, filetype="pdf") as document:
|
||||
drawings = document[0].get_drawings()
|
||||
items = [item for drawing in drawings for item in drawing["items"]]
|
||||
assert any(item[0] == "c" for item in items)
|
||||
assert all(all(cap == 1 for cap in drawing["lineCap"]) for drawing in drawings)
|
||||
assert all(drawing["lineJoin"] == 1 for drawing in drawings)
|
||||
|
||||
|
||||
def test_export_does_not_shrink_saved_typed_signature_size(tmp_path) -> None:
|
||||
source = tmp_path / "source.pdf"
|
||||
document = pymupdf.open()
|
||||
document.new_page(width=300, height=180)
|
||||
document.save(source)
|
||||
document.close()
|
||||
|
||||
annotations = [
|
||||
{
|
||||
"id": "typed-signature",
|
||||
"page": 0,
|
||||
"type": "signature",
|
||||
"rect": {"x": 20, "y": 20, "width": 160, "height": 60},
|
||||
"props": {
|
||||
"mode": "type",
|
||||
"text": "John Doe",
|
||||
"fontFamily": "Great Vibes",
|
||||
"fontSize": 48,
|
||||
"color": "#000000",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
exported = export_annotations(source, annotations, tmp_path / "assets")
|
||||
|
||||
with pymupdf.open(stream=exported, filetype="pdf") as document:
|
||||
spans = [
|
||||
span
|
||||
for block in document[0].get_text("dict")["blocks"]
|
||||
if "lines" in block
|
||||
for line in block["lines"]
|
||||
for span in line["spans"]
|
||||
if span["text"] == "John Doe"
|
||||
]
|
||||
assert spans
|
||||
span = spans[0]
|
||||
assert span["bbox"][2] - span["bbox"][0] == pytest.approx(160, abs=0.01)
|
||||
assert span["bbox"][3] - span["bbox"][1] == pytest.approx(
|
||||
(span["ascender"] - span["descender"]) * 48
|
||||
)
|
||||
|
||||
|
||||
def test_export_does_not_double_apply_typed_signature_glyph_bearing(tmp_path) -> None:
|
||||
source = tmp_path / "source.pdf"
|
||||
document = pymupdf.open()
|
||||
document.new_page(width=320, height=160)
|
||||
document.save(source)
|
||||
document.close()
|
||||
|
||||
annotations = [
|
||||
{
|
||||
"id": "offset-signature",
|
||||
"page": 0,
|
||||
"type": "signature",
|
||||
"rect": {"x": 120, "y": 40, "width": 190, "height": 70},
|
||||
"props": {
|
||||
"mode": "type",
|
||||
"text": "John Doe",
|
||||
"fontFamily": "Great Vibes",
|
||||
"fontSize": 48,
|
||||
# Stale records from the previous implementation may still
|
||||
# contain this value. The font already carries the bearing.
|
||||
"fontOffsetX": 4,
|
||||
"color": "#000000",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
exported = export_annotations(source, annotations, tmp_path / "assets")
|
||||
|
||||
with pymupdf.open(stream=exported, filetype="pdf") as document:
|
||||
spans = [
|
||||
span
|
||||
for block in document[0].get_text("dict")["blocks"]
|
||||
if "lines" in block
|
||||
for line in block["lines"]
|
||||
for span in line["spans"]
|
||||
if span["text"] == "John Doe"
|
||||
]
|
||||
assert spans
|
||||
assert spans[0]["bbox"][0] == pytest.approx(120)
|
||||
assert spans[0]["bbox"][2] - spans[0]["bbox"][0] == pytest.approx(190)
|
||||
|
||||
pixmap = document[0].get_pixmap(alpha=False)
|
||||
dark_x = [
|
||||
index % pixmap.width
|
||||
for index in range(pixmap.width * pixmap.height)
|
||||
if min(pixmap.samples[index * 3 : index * 3 + 3]) < 128
|
||||
]
|
||||
assert min(dark_x) == pytest.approx(117, abs=1)
|
||||
|
||||
|
||||
def test_outfit_italic_uses_browser_equivalent_synthetic_oblique() -> None:
|
||||
assert _needs_synthetic_italic("Outfit", False, "italic")
|
||||
assert _needs_synthetic_italic("Outfit", True, "italic")
|
||||
assert not _needs_synthetic_italic("Outfit", False, "normal")
|
||||
assert not _needs_synthetic_italic("Plus Jakarta Sans", False, "italic")
|
||||
|
||||
|
||||
def test_export_slants_outfit_italic_to_the_right(tmp_path) -> None:
|
||||
source = tmp_path / "source.pdf"
|
||||
document = pymupdf.open()
|
||||
document.new_page(width=360, height=140)
|
||||
document.save(source)
|
||||
document.close()
|
||||
|
||||
annotations = [
|
||||
{
|
||||
"page": 0,
|
||||
"type": "text",
|
||||
"rect": {"x": 20, "y": 20, "width": 120, "height": 80},
|
||||
"props": {
|
||||
"text": "test",
|
||||
"fontFamily": "Outfit",
|
||||
"fontSize": 48,
|
||||
"color": "#0000ff",
|
||||
},
|
||||
},
|
||||
{
|
||||
"page": 0,
|
||||
"type": "text",
|
||||
"rect": {"x": 180, "y": 20, "width": 120, "height": 80},
|
||||
"props": {
|
||||
"text": "test",
|
||||
"fontFamily": "Outfit",
|
||||
"fontSize": 48,
|
||||
"color": "#0000ff",
|
||||
"italic": True,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
exported = export_annotations(source, annotations, tmp_path / "assets")
|
||||
|
||||
with pymupdf.open(stream=exported, filetype="pdf") as document:
|
||||
spans = [
|
||||
span
|
||||
for block in document[0].get_text("dict")["blocks"]
|
||||
if "lines" in block
|
||||
for line in block["lines"]
|
||||
for span in line["spans"]
|
||||
if span["text"] == "test"
|
||||
]
|
||||
assert len(spans) == 2
|
||||
pixmap = document[0].get_pixmap(alpha=False)
|
||||
|
||||
def blue_pixels(row: int, start: int, end: int) -> list[int]:
|
||||
return [
|
||||
x
|
||||
for x in range(start, end)
|
||||
if pixmap.samples[(row * pixmap.width + x) * 3 + 2] > 120
|
||||
and pixmap.samples[(row * pixmap.width + x) * 3] < 100
|
||||
]
|
||||
|
||||
normal_top = min(blue_pixels(40, 0, 140)) - 20
|
||||
italic_top = min(blue_pixels(40, 140, 340)) - 180
|
||||
normal_bottom = min(blue_pixels(65, 0, 140)) - 20
|
||||
italic_bottom = min(blue_pixels(65, 140, 340)) - 180
|
||||
assert italic_top > normal_top + 5
|
||||
assert italic_bottom == pytest.approx(normal_bottom, abs=1)
|
||||
|
||||
|
||||
def test_export_preserves_canonical_stroke_widths(tmp_path) -> None:
|
||||
source = tmp_path / "source.pdf"
|
||||
document = pymupdf.open()
|
||||
document.new_page(width=240, height=320)
|
||||
document.save(source)
|
||||
document.close()
|
||||
|
||||
annotations = [
|
||||
{
|
||||
"id": "draw",
|
||||
"page": 0,
|
||||
"type": "draw",
|
||||
"rect": {"x": 20, "y": 20, "width": 100, "height": 40},
|
||||
"props": {
|
||||
"paths": [[0, 0], [100, 40]],
|
||||
"strokeColor": "#000000",
|
||||
"strokeWidth": 1.25,
|
||||
"strokeWidthUnit": "pdf",
|
||||
"opacity": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "shape",
|
||||
"page": 0,
|
||||
"type": "shape",
|
||||
"rect": {"x": 20, "y": 100, "width": 100, "height": 40},
|
||||
"props": {
|
||||
"kind": "rect",
|
||||
"strokeColor": "#000000",
|
||||
"fillColor": "transparent",
|
||||
"strokeWidth": 2.5,
|
||||
"strokeWidthUnit": "pdf",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
exported = export_annotations(source, annotations, tmp_path / "assets")
|
||||
|
||||
with pymupdf.open(stream=exported, filetype="pdf") as document:
|
||||
widths = [float(drawing["width"]) for drawing in document[0].get_drawings()]
|
||||
assert any(width == pytest.approx(1.25) for width in widths)
|
||||
assert any(width == pytest.approx(2.5) for width in widths)
|
||||
|
||||
|
||||
def test_export_does_not_mutate_annotation_input(tmp_path) -> None:
|
||||
source = tmp_path / "source.pdf"
|
||||
_source_pdf(source)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue