Add floating canvas panels, theme settings, and SVG line-spacing analysis.
Canvas Studio now uses dockable floating panels, app settings/help navigation, and improved SVG export; the backend adds an SVG line-spacing analysis API with SciPy acceleration and new design templates.
This commit is contained in:
@@ -4,6 +4,7 @@ python-multipart>=0.0.27
|
||||
pydantic>=2.10.0
|
||||
pillow>=10.0.0
|
||||
numpy>=2.0.0
|
||||
scipy>=1.11.0
|
||||
matplotlib>=3.10.0
|
||||
pandas>=2.0.0
|
||||
openpyxl>=3.1.0
|
||||
|
||||
@@ -8,6 +8,7 @@ import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -21,6 +22,7 @@ from PIL import Image, ImageDraw, ImageFont
|
||||
from core import config as wc_config
|
||||
from core.fonts import get_cached_font
|
||||
from .job_manager import JobManager
|
||||
from .line_spacing import analyze_svg_line_spacing_file
|
||||
from .log_config import get_logger
|
||||
from .runner import JobRunner
|
||||
from .schemas import (
|
||||
@@ -32,6 +34,8 @@ from .schemas import (
|
||||
JobLocationSearchResult,
|
||||
JobResult,
|
||||
JobStatus,
|
||||
LineSpacingAnalysisRequest,
|
||||
LineSpacingAnalysisSummary,
|
||||
Project,
|
||||
ProjectSummary,
|
||||
Template,
|
||||
@@ -973,6 +977,45 @@ def download_asset(asset_id: str):
|
||||
return FileResponse(path, media_type=media, filename=f"{meta['name']}{ext}")
|
||||
|
||||
|
||||
@app.post("/api/assets/{asset_id}/line-spacing", response_model=LineSpacingAnalysisSummary)
|
||||
def analyze_asset_line_spacing(
|
||||
asset_id: str,
|
||||
request: LineSpacingAnalysisRequest,
|
||||
) -> LineSpacingAnalysisSummary:
|
||||
d = _asset_dir(asset_id)
|
||||
meta = _read_asset_meta(d)
|
||||
if not meta:
|
||||
raise HTTPException(status_code=404, detail="asset not found")
|
||||
if meta.get("mime_type") != "image/svg+xml":
|
||||
raise HTTPException(status_code=400, detail="line spacing analysis only supports SVG assets")
|
||||
|
||||
path = d / "asset.svg"
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="asset file missing on disk")
|
||||
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
result = analyze_svg_line_spacing_file(
|
||||
path,
|
||||
percentile=request.percentile,
|
||||
element_width=request.elementWidth,
|
||||
element_height=request.elementHeight,
|
||||
sample_step=request.sampleStep,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
elapsed = time.perf_counter() - started
|
||||
log.info(
|
||||
"[API] line-spacing asset=%s percentile=%s curves=%s segments=%s elapsed=%.2fs",
|
||||
asset_id,
|
||||
result.percentile,
|
||||
result.curveCount,
|
||||
result.segmentCount,
|
||||
elapsed,
|
||||
)
|
||||
return LineSpacingAnalysisSummary(**result.as_dict())
|
||||
|
||||
|
||||
@app.delete("/api/assets/{asset_id}", status_code=204)
|
||||
def delete_asset(asset_id: str) -> None:
|
||||
d = _asset_dir(asset_id)
|
||||
|
||||
@@ -0,0 +1,937 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
Point = tuple[float, float]
|
||||
Matrix = tuple[float, float, float, float, float, float]
|
||||
Segment = tuple[int, float, float, float, float, float, float, float, float]
|
||||
|
||||
SVG_NS_RE = re.compile(r"\{[^}]+\}")
|
||||
PATH_TOKEN_RE = re.compile(
|
||||
r"[AaCcHhLlMmQqSsTtVvZz]|[-+]?(?:(?:\d*\.\d+)|(?:\d+\.?))(?:[eE][-+]?\d+)?"
|
||||
)
|
||||
TRANSFORM_RE = re.compile(r"([a-zA-Z]+)\(([^)]*)\)")
|
||||
NUMBER_RE = re.compile(r"[-+]?(?:(?:\d*\.\d+)|(?:\d+\.?))(?:[eE][-+]?\d+)?")
|
||||
|
||||
PATH_PARAM_COUNTS = {
|
||||
"a": 7,
|
||||
"c": 6,
|
||||
"h": 1,
|
||||
"l": 2,
|
||||
"m": 2,
|
||||
"q": 4,
|
||||
"s": 4,
|
||||
"t": 2,
|
||||
"v": 1,
|
||||
}
|
||||
|
||||
DPI = 96
|
||||
MM_PER_INCH = 25.4
|
||||
IDENTITY: Matrix = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SvgLineSpacingResult:
|
||||
percentile: float
|
||||
spacingPx: float
|
||||
spacingMm: float
|
||||
minSpacingPx: float
|
||||
minSpacingMm: float
|
||||
sampleStep: float
|
||||
curveCount: int
|
||||
segmentCount: int
|
||||
nearestCount: int
|
||||
sourceWidth: float
|
||||
sourceHeight: float
|
||||
elementWidth: float
|
||||
elementHeight: float
|
||||
computedAt: str
|
||||
closestPoints: dict[str, float] | None = None
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"percentile": self.percentile,
|
||||
"spacingPx": self.spacingPx,
|
||||
"spacingMm": self.spacingMm,
|
||||
"minSpacingPx": self.minSpacingPx,
|
||||
"minSpacingMm": self.minSpacingMm,
|
||||
"sampleStep": self.sampleStep,
|
||||
"curveCount": self.curveCount,
|
||||
"segmentCount": self.segmentCount,
|
||||
"nearestCount": self.nearestCount,
|
||||
"sourceWidth": self.sourceWidth,
|
||||
"sourceHeight": self.sourceHeight,
|
||||
"elementWidth": self.elementWidth,
|
||||
"elementHeight": self.elementHeight,
|
||||
"computedAt": self.computedAt,
|
||||
"closestPoints": self.closestPoints,
|
||||
}
|
||||
|
||||
|
||||
def analyze_svg_line_spacing_file(
|
||||
svg_path: Path,
|
||||
*,
|
||||
percentile: float,
|
||||
element_width: float,
|
||||
element_height: float,
|
||||
sample_step: float = 2.0,
|
||||
) -> SvgLineSpacingResult:
|
||||
svg_text = svg_path.read_text(encoding="utf-8", errors="replace")
|
||||
return analyze_svg_line_spacing(
|
||||
svg_text,
|
||||
percentile=percentile,
|
||||
element_width=element_width,
|
||||
element_height=element_height,
|
||||
sample_step=sample_step,
|
||||
)
|
||||
|
||||
|
||||
def analyze_svg_line_spacing(
|
||||
svg_text: str,
|
||||
*,
|
||||
percentile: float,
|
||||
element_width: float,
|
||||
element_height: float,
|
||||
sample_step: float = 2.0,
|
||||
) -> SvgLineSpacingResult:
|
||||
if element_width <= 0 or element_height <= 0:
|
||||
raise ValueError("element size must be positive")
|
||||
|
||||
percentile = max(0.0, min(100.0, float(percentile)))
|
||||
sample_step = max(0.5, float(sample_step or 2.0))
|
||||
|
||||
try:
|
||||
root = ET.fromstring(svg_text)
|
||||
except ET.ParseError as exc:
|
||||
raise ValueError("无法解析 SVG") from exc
|
||||
|
||||
source_width, source_height = read_svg_size(root)
|
||||
segments, curve_count = collect_segments(root, sample_step)
|
||||
if len(segments) < 2:
|
||||
raise ValueError("SVG 中可分析的轮廓线太少")
|
||||
|
||||
nearest_distances, min_distance, closest_points = compute_nearest_spacing(
|
||||
segments,
|
||||
max(8.0, sample_step * 8.0),
|
||||
)
|
||||
if not nearest_distances or not math.isfinite(min_distance):
|
||||
raise ValueError("没有找到可比较的不同轮廓曲线")
|
||||
|
||||
nearest_distances.sort()
|
||||
percentile_distance = percentile_value(nearest_distances, percentile)
|
||||
scale = min(element_width / source_width, element_height / source_height)
|
||||
spacing_px = percentile_distance * scale
|
||||
min_spacing_px = min_distance * scale
|
||||
|
||||
return SvgLineSpacingResult(
|
||||
percentile=percentile,
|
||||
spacingPx=spacing_px,
|
||||
spacingMm=px_to_mm(spacing_px),
|
||||
minSpacingPx=min_spacing_px,
|
||||
minSpacingMm=px_to_mm(min_spacing_px),
|
||||
sampleStep=sample_step,
|
||||
curveCount=curve_count,
|
||||
segmentCount=len(segments),
|
||||
nearestCount=len(nearest_distances),
|
||||
sourceWidth=source_width,
|
||||
sourceHeight=source_height,
|
||||
elementWidth=element_width,
|
||||
elementHeight=element_height,
|
||||
computedAt=datetime.now(timezone.utc).isoformat(),
|
||||
closestPoints=scale_closest_points(closest_points, scale),
|
||||
)
|
||||
|
||||
|
||||
def read_svg_size(root: ET.Element) -> tuple[float, float]:
|
||||
view_box = root.attrib.get("viewBox") or root.attrib.get("viewbox")
|
||||
if view_box:
|
||||
values = [parse_float(item) for item in re.split(r"[\s,]+", view_box.strip()) if item]
|
||||
if len(values) >= 4 and all(math.isfinite(v) for v in values[:4]) and values[2] > 0 and values[3] > 0:
|
||||
return values[2], values[3]
|
||||
|
||||
width = parse_svg_length(root.attrib.get("width"))
|
||||
height = parse_svg_length(root.attrib.get("height"))
|
||||
if width > 0 and height > 0:
|
||||
return width, height
|
||||
return 1000.0, 1000.0
|
||||
|
||||
|
||||
def collect_segments(root: ET.Element, sample_step: float) -> tuple[list[Segment], int]:
|
||||
segments: list[Segment] = []
|
||||
next_curve_id = 0
|
||||
|
||||
def visit(node: ET.Element, matrix: Matrix, hidden: bool) -> None:
|
||||
nonlocal next_curve_id
|
||||
hidden = hidden or element_hidden(node)
|
||||
node_matrix = multiply_matrix(matrix, parse_transform(node.attrib.get("transform", "")))
|
||||
tag = strip_ns(node.tag)
|
||||
|
||||
if not hidden and drawable_geometry(node):
|
||||
if tag == "path":
|
||||
next_curve_id = append_path_segments(
|
||||
node.attrib.get("d", ""),
|
||||
next_curve_id,
|
||||
sample_step,
|
||||
node_matrix,
|
||||
segments,
|
||||
)
|
||||
elif tag == "line":
|
||||
next_curve_id = append_line_element(node, next_curve_id, sample_step, node_matrix, segments)
|
||||
elif tag in {"polyline", "polygon"}:
|
||||
next_curve_id = append_poly_element(node, next_curve_id, sample_step, node_matrix, segments, tag == "polygon")
|
||||
elif tag == "rect":
|
||||
next_curve_id = append_rect_element(node, next_curve_id, sample_step, node_matrix, segments)
|
||||
elif tag in {"circle", "ellipse"}:
|
||||
next_curve_id = append_ellipse_element(node, next_curve_id, sample_step, node_matrix, segments, tag)
|
||||
|
||||
if tag not in {"defs", "clipPath", "mask", "pattern", "symbol"}:
|
||||
for child in list(node):
|
||||
visit(child, node_matrix, hidden)
|
||||
|
||||
visit(root, IDENTITY, False)
|
||||
return segments, next_curve_id
|
||||
|
||||
|
||||
def strip_ns(tag: str) -> str:
|
||||
return SVG_NS_RE.sub("", tag)
|
||||
|
||||
|
||||
def element_hidden(node: ET.Element) -> bool:
|
||||
style = parse_style(node.attrib.get("style", ""))
|
||||
display = (node.attrib.get("display") or style.get("display") or "").strip().lower()
|
||||
visibility = (node.attrib.get("visibility") or style.get("visibility") or "").strip().lower()
|
||||
return display == "none" or visibility == "hidden"
|
||||
|
||||
|
||||
def drawable_geometry(node: ET.Element) -> bool:
|
||||
tag = strip_ns(node.tag)
|
||||
if tag not in {"path", "line", "polyline", "polygon", "rect", "circle", "ellipse"}:
|
||||
return False
|
||||
|
||||
style = parse_style(node.attrib.get("style", ""))
|
||||
stroke = (node.attrib.get("stroke") or style.get("stroke") or "").strip().lower()
|
||||
fill = (node.attrib.get("fill") or style.get("fill") or "").strip().lower()
|
||||
if stroke and stroke not in {"none", "transparent"}:
|
||||
return True
|
||||
if not fill or fill not in {"none", "transparent"}:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def parse_style(raw: str) -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
for part in raw.split(";"):
|
||||
if ":" not in part:
|
||||
continue
|
||||
key, value = part.split(":", 1)
|
||||
result[key.strip().lower()] = value.strip()
|
||||
return result
|
||||
|
||||
|
||||
def append_path_segments(
|
||||
path_data: str,
|
||||
curve_id: int,
|
||||
sample_step: float,
|
||||
matrix: Matrix,
|
||||
output: list[Segment],
|
||||
) -> int:
|
||||
tokens = tokenize_path(path_data)
|
||||
if not tokens:
|
||||
return curve_id
|
||||
|
||||
index = 0
|
||||
command = ""
|
||||
current: Point = (0.0, 0.0)
|
||||
subpath_start: Point = (0.0, 0.0)
|
||||
active_curve_id = curve_id - 1
|
||||
last_cubic_control: Point | None = None
|
||||
last_quad_control: Point | None = None
|
||||
previous_command = ""
|
||||
|
||||
while index < len(tokens):
|
||||
token = tokens[index]
|
||||
if isinstance(token, str):
|
||||
command = token
|
||||
index += 1
|
||||
if not command:
|
||||
break
|
||||
|
||||
lower = command.lower()
|
||||
if lower == "z":
|
||||
if active_curve_id >= curve_id:
|
||||
append_sampled_line(current, subpath_start, active_curve_id, sample_step, matrix, output)
|
||||
current = subpath_start
|
||||
last_cubic_control = None
|
||||
last_quad_control = None
|
||||
previous_command = command
|
||||
command = ""
|
||||
continue
|
||||
|
||||
param_count = PATH_PARAM_COUNTS.get(lower)
|
||||
if not param_count:
|
||||
break
|
||||
|
||||
first_move = lower == "m"
|
||||
while has_number_run(tokens, index, param_count):
|
||||
values = [float(tokens[index + offset]) for offset in range(param_count)]
|
||||
index += param_count
|
||||
|
||||
if lower == "m":
|
||||
point = absolute_point(command, current, values[0], values[1])
|
||||
if first_move:
|
||||
active_curve_id = curve_id
|
||||
curve_id += 1
|
||||
subpath_start = point
|
||||
current = point
|
||||
first_move = False
|
||||
else:
|
||||
append_sampled_line(current, point, active_curve_id, sample_step, matrix, output)
|
||||
current = point
|
||||
last_cubic_control = None
|
||||
last_quad_control = None
|
||||
elif lower == "l":
|
||||
point = absolute_point(command, current, values[0], values[1])
|
||||
append_sampled_line(current, point, active_curve_id, sample_step, matrix, output)
|
||||
current = point
|
||||
last_cubic_control = None
|
||||
last_quad_control = None
|
||||
elif lower == "h":
|
||||
x = current[0] + values[0] if command.islower() else values[0]
|
||||
point = (x, current[1])
|
||||
append_sampled_line(current, point, active_curve_id, sample_step, matrix, output)
|
||||
current = point
|
||||
last_cubic_control = None
|
||||
last_quad_control = None
|
||||
elif lower == "v":
|
||||
y = current[1] + values[0] if command.islower() else values[0]
|
||||
point = (current[0], y)
|
||||
append_sampled_line(current, point, active_curve_id, sample_step, matrix, output)
|
||||
current = point
|
||||
last_cubic_control = None
|
||||
last_quad_control = None
|
||||
elif lower == "c":
|
||||
p1 = absolute_point(command, current, values[0], values[1])
|
||||
p2 = absolute_point(command, current, values[2], values[3])
|
||||
point = absolute_point(command, current, values[4], values[5])
|
||||
append_cubic(current, p1, p2, point, active_curve_id, sample_step, matrix, output)
|
||||
current = point
|
||||
last_cubic_control = p2
|
||||
last_quad_control = None
|
||||
elif lower == "s":
|
||||
p1 = reflect_point(current, last_cubic_control) if previous_command.lower() in {"c", "s"} else current
|
||||
p2 = absolute_point(command, current, values[0], values[1])
|
||||
point = absolute_point(command, current, values[2], values[3])
|
||||
append_cubic(current, p1, p2, point, active_curve_id, sample_step, matrix, output)
|
||||
current = point
|
||||
last_cubic_control = p2
|
||||
last_quad_control = None
|
||||
elif lower == "q":
|
||||
p1 = absolute_point(command, current, values[0], values[1])
|
||||
point = absolute_point(command, current, values[2], values[3])
|
||||
append_quadratic(current, p1, point, active_curve_id, sample_step, matrix, output)
|
||||
current = point
|
||||
last_quad_control = p1
|
||||
last_cubic_control = None
|
||||
elif lower == "t":
|
||||
p1 = reflect_point(current, last_quad_control) if previous_command.lower() in {"q", "t"} else current
|
||||
point = absolute_point(command, current, values[0], values[1])
|
||||
append_quadratic(current, p1, point, active_curve_id, sample_step, matrix, output)
|
||||
current = point
|
||||
last_quad_control = p1
|
||||
last_cubic_control = None
|
||||
elif lower == "a":
|
||||
rx, ry, angle, large_arc, sweep, x, y = values
|
||||
point = absolute_point(command, current, x, y)
|
||||
append_arc(current, rx, ry, angle, large_arc, sweep, point, active_curve_id, sample_step, matrix, output)
|
||||
current = point
|
||||
last_cubic_control = None
|
||||
last_quad_control = None
|
||||
|
||||
previous_command = command
|
||||
if index < len(tokens) and isinstance(tokens[index], str):
|
||||
break
|
||||
|
||||
return curve_id
|
||||
|
||||
|
||||
def tokenize_path(path_data: str) -> list[str | float]:
|
||||
tokens: list[str | float] = []
|
||||
for match in PATH_TOKEN_RE.finditer(path_data):
|
||||
raw = match.group(0)
|
||||
if re.fullmatch(r"[AaCcHhLlMmQqSsTtVvZz]", raw):
|
||||
tokens.append(raw)
|
||||
else:
|
||||
tokens.append(float(raw))
|
||||
return tokens
|
||||
|
||||
|
||||
def has_number_run(tokens: list[str | float], index: int, count: int) -> bool:
|
||||
if index + count > len(tokens):
|
||||
return False
|
||||
return all(not isinstance(tokens[index + offset], str) for offset in range(count))
|
||||
|
||||
|
||||
def absolute_point(command: str, current: Point, x: float, y: float) -> Point:
|
||||
if command.islower():
|
||||
return current[0] + x, current[1] + y
|
||||
return x, y
|
||||
|
||||
|
||||
def reflect_point(origin: Point, point: Point | None) -> Point:
|
||||
if point is None:
|
||||
return origin
|
||||
return 2 * origin[0] - point[0], 2 * origin[1] - point[1]
|
||||
|
||||
|
||||
def append_line_element(node: ET.Element, curve_id: int, sample_step: float, matrix: Matrix, output: list[Segment]) -> int:
|
||||
p1 = (parse_svg_length(node.attrib.get("x1")), parse_svg_length(node.attrib.get("y1")))
|
||||
p2 = (parse_svg_length(node.attrib.get("x2")), parse_svg_length(node.attrib.get("y2")))
|
||||
append_sampled_line(p1, p2, curve_id, sample_step, matrix, output)
|
||||
return curve_id + 1
|
||||
|
||||
|
||||
def append_poly_element(
|
||||
node: ET.Element,
|
||||
curve_id: int,
|
||||
sample_step: float,
|
||||
matrix: Matrix,
|
||||
output: list[Segment],
|
||||
close: bool,
|
||||
) -> int:
|
||||
points = parse_points(node.attrib.get("points", ""))
|
||||
if len(points) < 2:
|
||||
return curve_id
|
||||
for p1, p2 in zip(points, points[1:]):
|
||||
append_sampled_line(p1, p2, curve_id, sample_step, matrix, output)
|
||||
if close:
|
||||
append_sampled_line(points[-1], points[0], curve_id, sample_step, matrix, output)
|
||||
return curve_id + 1
|
||||
|
||||
|
||||
def append_rect_element(node: ET.Element, curve_id: int, sample_step: float, matrix: Matrix, output: list[Segment]) -> int:
|
||||
x = parse_svg_length(node.attrib.get("x"))
|
||||
y = parse_svg_length(node.attrib.get("y"))
|
||||
width = parse_svg_length(node.attrib.get("width"))
|
||||
height = parse_svg_length(node.attrib.get("height"))
|
||||
if width <= 0 or height <= 0:
|
||||
return curve_id
|
||||
points = [(x, y), (x + width, y), (x + width, y + height), (x, y + height)]
|
||||
for p1, p2 in zip(points, points[1:] + points[:1]):
|
||||
append_sampled_line(p1, p2, curve_id, sample_step, matrix, output)
|
||||
return curve_id + 1
|
||||
|
||||
|
||||
def append_ellipse_element(
|
||||
node: ET.Element,
|
||||
curve_id: int,
|
||||
sample_step: float,
|
||||
matrix: Matrix,
|
||||
output: list[Segment],
|
||||
tag: str,
|
||||
) -> int:
|
||||
if tag == "circle":
|
||||
cx = parse_svg_length(node.attrib.get("cx"))
|
||||
cy = parse_svg_length(node.attrib.get("cy"))
|
||||
rx = ry = parse_svg_length(node.attrib.get("r"))
|
||||
else:
|
||||
cx = parse_svg_length(node.attrib.get("cx"))
|
||||
cy = parse_svg_length(node.attrib.get("cy"))
|
||||
rx = parse_svg_length(node.attrib.get("rx"))
|
||||
ry = parse_svg_length(node.attrib.get("ry"))
|
||||
if rx <= 0 or ry <= 0:
|
||||
return curve_id
|
||||
circumference = math.pi * (3 * (rx + ry) - math.sqrt((3 * rx + ry) * (rx + 3 * ry)))
|
||||
steps = max(24, int(math.ceil(circumference / sample_step)))
|
||||
raw_points = [
|
||||
(cx + math.cos((math.tau * i) / steps) * rx, cy + math.sin((math.tau * i) / steps) * ry)
|
||||
for i in range(steps + 1)
|
||||
]
|
||||
append_points(raw_points, curve_id, matrix, output)
|
||||
return curve_id + 1
|
||||
|
||||
|
||||
def append_sampled_line(p1: Point, p2: Point, curve_id: int, sample_step: float, matrix: Matrix, output: list[Segment]) -> None:
|
||||
if curve_id < 0:
|
||||
return
|
||||
tp1 = transform_point(matrix, p1)
|
||||
tp2 = transform_point(matrix, p2)
|
||||
distance = point_distance(tp1, tp2)
|
||||
steps = max(1, int(math.ceil(distance / sample_step)))
|
||||
points = [lerp_point(p1, p2, i / steps) for i in range(steps + 1)]
|
||||
append_points(points, curve_id, matrix, output)
|
||||
|
||||
|
||||
def append_quadratic(
|
||||
p0: Point,
|
||||
p1: Point,
|
||||
p2: Point,
|
||||
curve_id: int,
|
||||
sample_step: float,
|
||||
matrix: Matrix,
|
||||
output: list[Segment],
|
||||
) -> None:
|
||||
tp = [transform_point(matrix, p) for p in (p0, p1, p2)]
|
||||
control_length = point_distance(tp[0], tp[1]) + point_distance(tp[1], tp[2])
|
||||
steps = max(4, int(math.ceil(control_length / sample_step)))
|
||||
points = []
|
||||
for i in range(steps + 1):
|
||||
t = i / steps
|
||||
mt = 1 - t
|
||||
points.append((
|
||||
mt * mt * p0[0] + 2 * mt * t * p1[0] + t * t * p2[0],
|
||||
mt * mt * p0[1] + 2 * mt * t * p1[1] + t * t * p2[1],
|
||||
))
|
||||
append_points(points, curve_id, matrix, output)
|
||||
|
||||
|
||||
def append_cubic(
|
||||
p0: Point,
|
||||
p1: Point,
|
||||
p2: Point,
|
||||
p3: Point,
|
||||
curve_id: int,
|
||||
sample_step: float,
|
||||
matrix: Matrix,
|
||||
output: list[Segment],
|
||||
) -> None:
|
||||
tp = [transform_point(matrix, p) for p in (p0, p1, p2, p3)]
|
||||
control_length = point_distance(tp[0], tp[1]) + point_distance(tp[1], tp[2]) + point_distance(tp[2], tp[3])
|
||||
steps = max(6, int(math.ceil(control_length / sample_step)))
|
||||
points = []
|
||||
for i in range(steps + 1):
|
||||
t = i / steps
|
||||
mt = 1 - t
|
||||
points.append((
|
||||
mt**3 * p0[0] + 3 * mt * mt * t * p1[0] + 3 * mt * t * t * p2[0] + t**3 * p3[0],
|
||||
mt**3 * p0[1] + 3 * mt * mt * t * p1[1] + 3 * mt * t * t * p2[1] + t**3 * p3[1],
|
||||
))
|
||||
append_points(points, curve_id, matrix, output)
|
||||
|
||||
|
||||
def append_arc(
|
||||
start: Point,
|
||||
rx: float,
|
||||
ry: float,
|
||||
angle_degrees: float,
|
||||
large_arc: float,
|
||||
sweep: float,
|
||||
end: Point,
|
||||
curve_id: int,
|
||||
sample_step: float,
|
||||
matrix: Matrix,
|
||||
output: list[Segment],
|
||||
) -> None:
|
||||
if rx == 0 or ry == 0 or start == end:
|
||||
append_sampled_line(start, end, curve_id, sample_step, matrix, output)
|
||||
return
|
||||
|
||||
rx = abs(rx)
|
||||
ry = abs(ry)
|
||||
phi = math.radians(angle_degrees % 360)
|
||||
cos_phi = math.cos(phi)
|
||||
sin_phi = math.sin(phi)
|
||||
dx = (start[0] - end[0]) / 2
|
||||
dy = (start[1] - end[1]) / 2
|
||||
x1p = cos_phi * dx + sin_phi * dy
|
||||
y1p = -sin_phi * dx + cos_phi * dy
|
||||
|
||||
radii_check = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry)
|
||||
if radii_check > 1:
|
||||
scale = math.sqrt(radii_check)
|
||||
rx *= scale
|
||||
ry *= scale
|
||||
|
||||
sign = -1 if bool(large_arc) == bool(sweep) else 1
|
||||
numerator = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p
|
||||
denominator = rx * rx * y1p * y1p + ry * ry * x1p * x1p
|
||||
factor = sign * math.sqrt(max(0.0, numerator / denominator)) if denominator else 0.0
|
||||
cxp = factor * (rx * y1p / ry)
|
||||
cyp = factor * (-ry * x1p / rx)
|
||||
cx = cos_phi * cxp - sin_phi * cyp + (start[0] + end[0]) / 2
|
||||
cy = sin_phi * cxp + cos_phi * cyp + (start[1] + end[1]) / 2
|
||||
|
||||
theta1 = vector_angle((1, 0), ((x1p - cxp) / rx, (y1p - cyp) / ry))
|
||||
delta = vector_angle(
|
||||
((x1p - cxp) / rx, (y1p - cyp) / ry),
|
||||
((-x1p - cxp) / rx, (-y1p - cyp) / ry),
|
||||
)
|
||||
if not sweep and delta > 0:
|
||||
delta -= math.tau
|
||||
elif sweep and delta < 0:
|
||||
delta += math.tau
|
||||
|
||||
arc_length = abs(delta) * max(rx, ry)
|
||||
steps = max(4, int(math.ceil(arc_length / sample_step)))
|
||||
points = []
|
||||
for i in range(steps + 1):
|
||||
theta = theta1 + delta * (i / steps)
|
||||
x = cx + cos_phi * rx * math.cos(theta) - sin_phi * ry * math.sin(theta)
|
||||
y = cy + sin_phi * rx * math.cos(theta) + cos_phi * ry * math.sin(theta)
|
||||
points.append((x, y))
|
||||
append_points(points, curve_id, matrix, output)
|
||||
|
||||
|
||||
def vector_angle(u: Point, v: Point) -> float:
|
||||
dot_value = u[0] * v[0] + u[1] * v[1]
|
||||
det_value = u[0] * v[1] - u[1] * v[0]
|
||||
return math.atan2(det_value, dot_value)
|
||||
|
||||
|
||||
def append_points(points: list[Point], curve_id: int, matrix: Matrix, output: list[Segment]) -> None:
|
||||
transformed = [transform_point(matrix, point) for point in points]
|
||||
for a, b in zip(transformed, transformed[1:]):
|
||||
length = point_distance(a, b)
|
||||
if length <= 0.0001:
|
||||
continue
|
||||
ax, ay = a
|
||||
bx, by = b
|
||||
output.append((
|
||||
curve_id,
|
||||
ax,
|
||||
ay,
|
||||
bx,
|
||||
by,
|
||||
min(ax, bx),
|
||||
min(ay, by),
|
||||
max(ax, bx),
|
||||
max(ay, by),
|
||||
))
|
||||
|
||||
|
||||
def compute_nearest_spacing(
|
||||
segments: list[Segment],
|
||||
cell_size: float,
|
||||
) -> tuple[list[float], float, dict[str, float] | None]:
|
||||
try:
|
||||
return compute_nearest_spacing_kdtree(segments)
|
||||
except ImportError:
|
||||
return compute_nearest_spacing_grid(segments, cell_size)
|
||||
|
||||
|
||||
def compute_nearest_spacing_kdtree(segments: list[Segment]) -> tuple[list[float], float, dict[str, float] | None]:
|
||||
try:
|
||||
import numpy as np
|
||||
from scipy.spatial import cKDTree
|
||||
except ImportError:
|
||||
scipy_site = os.environ.get("WORDCLOUD_SCIPY_SITE", "")
|
||||
if scipy_site and scipy_site not in sys.path:
|
||||
sys.path.append(scipy_site)
|
||||
import numpy as np
|
||||
from scipy.spatial import cKDTree
|
||||
|
||||
points = sample_points_from_segments(segments)
|
||||
if len(points) < 2:
|
||||
return [], math.inf, None
|
||||
|
||||
coordinates = np.asarray([(point[1], point[2]) for point in points], dtype=np.float64)
|
||||
curve_ids = np.asarray([point[0] for point in points], dtype=np.int32)
|
||||
tree = cKDTree(coordinates)
|
||||
k = min(128, len(points))
|
||||
chunk_size = 50000
|
||||
nearest_distances: list[float] = []
|
||||
min_distance = math.inf
|
||||
closest_points: dict[str, float] | None = None
|
||||
|
||||
for start in range(0, len(points), chunk_size):
|
||||
stop = min(start + chunk_size, len(points))
|
||||
try:
|
||||
distances, indices = tree.query(coordinates[start:stop], k=k, workers=-1)
|
||||
except TypeError:
|
||||
distances, indices = tree.query(coordinates[start:stop], k=k)
|
||||
if k == 1:
|
||||
distances = distances[:, np.newaxis]
|
||||
indices = indices[:, np.newaxis]
|
||||
|
||||
candidate_curve_ids = curve_ids[indices]
|
||||
current_curve_ids = curve_ids[start:stop, np.newaxis]
|
||||
valid = candidate_curve_ids != current_curve_ids
|
||||
found = valid.any(axis=1)
|
||||
if not found.any():
|
||||
continue
|
||||
|
||||
first_valid = valid.argmax(axis=1)
|
||||
row_numbers = np.arange(stop - start)
|
||||
found_rows = row_numbers[found]
|
||||
found_columns = first_valid[found]
|
||||
values = distances[found_rows, found_columns]
|
||||
finite = np.isfinite(values)
|
||||
if not finite.any():
|
||||
continue
|
||||
|
||||
values = values[finite]
|
||||
found_rows = found_rows[finite]
|
||||
found_columns = found_columns[finite]
|
||||
nearest_distances.extend(values.tolist())
|
||||
|
||||
local_min_index = int(np.argmin(values))
|
||||
local_min = float(values[local_min_index])
|
||||
if local_min < min_distance:
|
||||
source_index = start + int(found_rows[local_min_index])
|
||||
target_index = int(indices[found_rows[local_min_index], found_columns[local_min_index]])
|
||||
min_distance = local_min
|
||||
closest_points = {
|
||||
"ax": float(coordinates[source_index, 0]),
|
||||
"ay": float(coordinates[source_index, 1]),
|
||||
"bx": float(coordinates[target_index, 0]),
|
||||
"by": float(coordinates[target_index, 1]),
|
||||
}
|
||||
|
||||
return nearest_distances, min_distance, closest_points
|
||||
|
||||
|
||||
def compute_nearest_spacing_grid(
|
||||
segments: list[Segment],
|
||||
cell_size: float,
|
||||
) -> tuple[list[float], float, dict[str, float] | None]:
|
||||
points = sample_points_from_segments(segments)
|
||||
grid: dict[tuple[int, int], list[int]] = {}
|
||||
min_grid_x = min_grid_y = math.inf
|
||||
max_grid_x = max_grid_y = -math.inf
|
||||
|
||||
for index, (_, x, y) in enumerate(points):
|
||||
cx = math.floor(x / cell_size)
|
||||
cy = math.floor(y / cell_size)
|
||||
min_grid_x = min(min_grid_x, cx)
|
||||
min_grid_y = min(min_grid_y, cy)
|
||||
max_grid_x = max(max_grid_x, cx)
|
||||
max_grid_y = max(max_grid_y, cy)
|
||||
grid.setdefault((cx, cy), []).append(index)
|
||||
|
||||
max_ring = int(max(max_grid_x - min_grid_x, max_grid_y - min_grid_y) + 2) if math.isfinite(min_grid_x) else 0
|
||||
nearest_distances: list[float] = []
|
||||
min_distance = math.inf
|
||||
closest_points: dict[str, float] | None = None
|
||||
|
||||
for index, point in enumerate(points):
|
||||
curve_id, x, y = point
|
||||
base_cx = math.floor(x / cell_size)
|
||||
base_cy = math.floor(y / cell_size)
|
||||
seen: set[int] = set()
|
||||
nearest_sq = math.inf
|
||||
|
||||
for ring in range(max_ring + 1):
|
||||
for cx in range(base_cx - ring, base_cx + ring + 1):
|
||||
for cy in range(base_cy - ring, base_cy + ring + 1):
|
||||
if ring > 0 and base_cx - ring < cx < base_cx + ring and base_cy - ring < cy < base_cy + ring:
|
||||
continue
|
||||
bucket = grid.get((cx, cy))
|
||||
if not bucket:
|
||||
continue
|
||||
for candidate_index in bucket:
|
||||
if candidate_index == index or candidate_index in seen:
|
||||
continue
|
||||
seen.add(candidate_index)
|
||||
candidate_curve_id, candidate_x, candidate_y = points[candidate_index]
|
||||
if candidate_curve_id == curve_id:
|
||||
continue
|
||||
dx = x - candidate_x
|
||||
dy = y - candidate_y
|
||||
distance_sq = dx * dx + dy * dy
|
||||
if distance_sq < nearest_sq:
|
||||
nearest_sq = distance_sq
|
||||
if distance_sq < min_distance * min_distance:
|
||||
distance = math.sqrt(distance_sq)
|
||||
min_distance = distance
|
||||
closest_points = {
|
||||
"ax": x,
|
||||
"ay": y,
|
||||
"bx": candidate_x,
|
||||
"by": candidate_y,
|
||||
}
|
||||
if math.isfinite(nearest_sq) and ring * cell_size > math.sqrt(nearest_sq) + cell_size * 2:
|
||||
break
|
||||
|
||||
if math.isfinite(nearest_sq):
|
||||
nearest_distances.append(math.sqrt(nearest_sq))
|
||||
|
||||
return nearest_distances, min_distance, closest_points
|
||||
|
||||
|
||||
def sample_points_from_segments(segments: list[Segment]) -> list[tuple[int, float, float]]:
|
||||
points: list[tuple[int, float, float]] = []
|
||||
for index, segment in enumerate(segments):
|
||||
curve_id = int(segment[0])
|
||||
points.append((curve_id, segment[1], segment[2]))
|
||||
next_segment = segments[index + 1] if index + 1 < len(segments) else None
|
||||
if next_segment is None or int(next_segment[0]) != curve_id:
|
||||
points.append((curve_id, segment[3], segment[4]))
|
||||
return points
|
||||
|
||||
|
||||
def cell_bounds(segment: Segment, cell_size: float) -> tuple[int, int, int, int]:
|
||||
return (
|
||||
math.floor(segment[5] / cell_size),
|
||||
math.floor(segment[6] / cell_size),
|
||||
math.floor(segment[7] / cell_size),
|
||||
math.floor(segment[8] / cell_size),
|
||||
)
|
||||
|
||||
|
||||
def segment_distance(a: Segment, b: Segment) -> tuple[float, Point, Point]:
|
||||
pa, pb = closest_segment_points((a[1], a[2]), (a[3], a[4]), (b[1], b[2]), (b[3], b[4]))
|
||||
return point_distance(pa, pb), pa, pb
|
||||
|
||||
|
||||
def closest_segment_points(p1: Point, q1: Point, p2: Point, q2: Point) -> tuple[Point, Point]:
|
||||
d1 = sub(q1, p1)
|
||||
d2 = sub(q2, p2)
|
||||
r = sub(p1, p2)
|
||||
a = dot(d1, d1)
|
||||
e = dot(d2, d2)
|
||||
f = dot(d2, r)
|
||||
s = 0.0
|
||||
t = 0.0
|
||||
epsilon = 1e-9
|
||||
|
||||
if a <= epsilon and e <= epsilon:
|
||||
return p1, p2
|
||||
if a <= epsilon:
|
||||
t = clamp01(f / e)
|
||||
else:
|
||||
c = dot(d1, r)
|
||||
if e <= epsilon:
|
||||
s = clamp01(-c / a)
|
||||
else:
|
||||
b = dot(d1, d2)
|
||||
denom = a * e - b * b
|
||||
s = clamp01((b * f - c * e) / denom) if denom != 0 else 0.0
|
||||
t_nom = b * s + f
|
||||
if t_nom < 0:
|
||||
t = 0.0
|
||||
s = clamp01(-c / a)
|
||||
elif t_nom > e:
|
||||
t = 1.0
|
||||
s = clamp01((b - c) / a)
|
||||
else:
|
||||
t = t_nom / e
|
||||
|
||||
return add(p1, mul(d1, s)), add(p2, mul(d2, t))
|
||||
|
||||
|
||||
def parse_transform(raw: str) -> Matrix:
|
||||
matrix = IDENTITY
|
||||
for name, args_raw in TRANSFORM_RE.findall(raw):
|
||||
args = [parse_float(item) for item in NUMBER_RE.findall(args_raw)]
|
||||
name = name.lower()
|
||||
next_matrix = IDENTITY
|
||||
if name == "matrix" and len(args) >= 6:
|
||||
next_matrix = (args[0], args[1], args[2], args[3], args[4], args[5])
|
||||
elif name == "translate" and args:
|
||||
next_matrix = (1.0, 0.0, 0.0, 1.0, args[0], args[1] if len(args) > 1 else 0.0)
|
||||
elif name == "scale" and args:
|
||||
sx = args[0]
|
||||
sy = args[1] if len(args) > 1 else sx
|
||||
next_matrix = (sx, 0.0, 0.0, sy, 0.0, 0.0)
|
||||
elif name == "rotate" and args:
|
||||
angle = math.radians(args[0])
|
||||
cos_a = math.cos(angle)
|
||||
sin_a = math.sin(angle)
|
||||
rotate = (cos_a, sin_a, -sin_a, cos_a, 0.0, 0.0)
|
||||
if len(args) >= 3:
|
||||
next_matrix = multiply_matrix(
|
||||
multiply_matrix((1.0, 0.0, 0.0, 1.0, args[1], args[2]), rotate),
|
||||
(1.0, 0.0, 0.0, 1.0, -args[1], -args[2]),
|
||||
)
|
||||
else:
|
||||
next_matrix = rotate
|
||||
elif name == "skewx" and args:
|
||||
next_matrix = (1.0, 0.0, math.tan(math.radians(args[0])), 1.0, 0.0, 0.0)
|
||||
elif name == "skewy" and args:
|
||||
next_matrix = (1.0, math.tan(math.radians(args[0])), 0.0, 1.0, 0.0, 0.0)
|
||||
matrix = multiply_matrix(matrix, next_matrix)
|
||||
return matrix
|
||||
|
||||
|
||||
def multiply_matrix(left: Matrix, right: Matrix) -> Matrix:
|
||||
a1, b1, c1, d1, e1, f1 = left
|
||||
a2, b2, c2, d2, e2, f2 = right
|
||||
return (
|
||||
a1 * a2 + c1 * b2,
|
||||
b1 * a2 + d1 * b2,
|
||||
a1 * c2 + c1 * d2,
|
||||
b1 * c2 + d1 * d2,
|
||||
a1 * e2 + c1 * f2 + e1,
|
||||
b1 * e2 + d1 * f2 + f1,
|
||||
)
|
||||
|
||||
|
||||
def transform_point(matrix: Matrix, point: Point) -> Point:
|
||||
a, b, c, d, e, f = matrix
|
||||
x, y = point
|
||||
return a * x + c * y + e, b * x + d * y + f
|
||||
|
||||
|
||||
def parse_points(raw: str) -> list[Point]:
|
||||
values = [parse_float(item) for item in NUMBER_RE.findall(raw)]
|
||||
return [(values[i], values[i + 1]) for i in range(0, len(values) - 1, 2)]
|
||||
|
||||
|
||||
def parse_svg_length(raw: str | None) -> float:
|
||||
if not raw:
|
||||
return 0.0
|
||||
return parse_float(raw)
|
||||
|
||||
|
||||
def parse_float(raw: str | float) -> float:
|
||||
if isinstance(raw, float):
|
||||
return raw
|
||||
try:
|
||||
return float(raw)
|
||||
except ValueError:
|
||||
match = NUMBER_RE.search(raw)
|
||||
return float(match.group(0)) if match else 0.0
|
||||
|
||||
|
||||
def percentile_value(sorted_values: list[float], percentile: float) -> float:
|
||||
if not sorted_values:
|
||||
return math.inf
|
||||
if percentile <= 0:
|
||||
return sorted_values[0]
|
||||
index = math.floor((percentile / 100.0) * (len(sorted_values) - 1))
|
||||
return sorted_values[max(0, min(len(sorted_values) - 1, index))]
|
||||
|
||||
|
||||
def px_to_mm(px: float) -> float:
|
||||
return (px / DPI) * MM_PER_INCH
|
||||
|
||||
|
||||
def scale_closest_points(points: dict[str, float] | None, scale: float) -> dict[str, float] | None:
|
||||
if not points:
|
||||
return None
|
||||
return {key: value * scale for key, value in points.items()}
|
||||
|
||||
|
||||
def point_distance(a: Point, b: Point) -> float:
|
||||
return math.hypot(a[0] - b[0], a[1] - b[1])
|
||||
|
||||
|
||||
def lerp_point(a: Point, b: Point, t: float) -> Point:
|
||||
return a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t
|
||||
|
||||
|
||||
def sub(a: Point, b: Point) -> Point:
|
||||
return a[0] - b[0], a[1] - b[1]
|
||||
|
||||
|
||||
def add(a: Point, b: Point) -> Point:
|
||||
return a[0] + b[0], a[1] + b[1]
|
||||
|
||||
|
||||
def mul(a: Point, value: float) -> Point:
|
||||
return a[0] * value, a[1] * value
|
||||
|
||||
|
||||
def dot(a: Point, b: Point) -> float:
|
||||
return a[0] * b[0] + a[1] * b[1]
|
||||
|
||||
|
||||
def clamp01(value: float) -> float:
|
||||
return max(0.0, min(1.0, value))
|
||||
@@ -100,6 +100,38 @@ class Asset(BaseModel):
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class LineSpacingAnalysisRequest(BaseModel):
|
||||
percentile: float = Field(default=3, ge=0, le=100)
|
||||
elementWidth: float = Field(gt=0)
|
||||
elementHeight: float = Field(gt=0)
|
||||
sampleStep: float = Field(default=2, gt=0)
|
||||
|
||||
|
||||
class LineSpacingClosestPoints(BaseModel):
|
||||
ax: float
|
||||
ay: float
|
||||
bx: float
|
||||
by: float
|
||||
|
||||
|
||||
class LineSpacingAnalysisSummary(BaseModel):
|
||||
percentile: float
|
||||
spacingPx: float
|
||||
spacingMm: float
|
||||
minSpacingPx: float
|
||||
minSpacingMm: float
|
||||
sampleStep: float
|
||||
curveCount: int
|
||||
segmentCount: int
|
||||
nearestCount: int
|
||||
sourceWidth: float
|
||||
sourceHeight: float
|
||||
elementWidth: float
|
||||
elementHeight: float
|
||||
computedAt: str
|
||||
closestPoints: LineSpacingClosestPoints | None = None
|
||||
|
||||
|
||||
class DesignTemplate(BaseModel):
|
||||
template_id: str
|
||||
name: str
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"template_id": "tmpl_38311b4587e04821966231f4eb4ce9d7",
|
||||
"name": "陪你度过漫长岁月",
|
||||
"description": "",
|
||||
"document": {
|
||||
"width": 559.3700787401575,
|
||||
"height": 793.7007874015749,
|
||||
"background": "#ffffff",
|
||||
"layers": [
|
||||
{
|
||||
"id": "layer-default",
|
||||
"name": "图层 1",
|
||||
"visible": true,
|
||||
"locked": false
|
||||
},
|
||||
{
|
||||
"id": "7a0f1776-a77e-49cd-baf3-d343ca7cb31a",
|
||||
"name": "原图遮罩",
|
||||
"visible": true,
|
||||
"locked": false,
|
||||
"folderId": "3c987a17-de95-472d-8755-1a1f539675e2"
|
||||
},
|
||||
{
|
||||
"id": "833f7ef1-74ec-4848-b1f6-60e6885ec6a4",
|
||||
"name": "词云",
|
||||
"visible": true,
|
||||
"locked": false,
|
||||
"folderId": "3c987a17-de95-472d-8755-1a1f539675e2"
|
||||
}
|
||||
],
|
||||
"layerFolders": [
|
||||
{
|
||||
"id": "3c987a17-de95-472d-8755-1a1f539675e2",
|
||||
"name": "词云文件夹 22:13",
|
||||
"layerIds": [
|
||||
"7a0f1776-a77e-49cd-baf3-d343ca7cb31a",
|
||||
"833f7ef1-74ec-4848-b1f6-60e6885ec6a4"
|
||||
],
|
||||
"collapsed": false
|
||||
}
|
||||
],
|
||||
"elements": [
|
||||
{
|
||||
"id": "b0080484-2f6a-4289-b7f3-958ad0543b1d",
|
||||
"type": "sticker",
|
||||
"assetId": "asset_26fccdb1f8c74e8689a1db2f3e320e91",
|
||||
"layerId": "7a0f1776-a77e-49cd-baf3-d343ca7cb31a",
|
||||
"groupId": "8b2d6bad-e0d4-4243-b079-e0bb7aa0abde",
|
||||
"x": -176,
|
||||
"y": 13,
|
||||
"width": 931,
|
||||
"height": 588,
|
||||
"rotation": 0,
|
||||
"opacity": 0.34
|
||||
},
|
||||
{
|
||||
"id": "bac6086f-7b64-4e81-b78d-ec57f08379e9",
|
||||
"type": "sticker",
|
||||
"assetId": "asset_66d7cf53d2d64d3baf770f292d3bedd1",
|
||||
"layerId": "833f7ef1-74ec-4848-b1f6-60e6885ec6a4",
|
||||
"groupId": "8b2d6bad-e0d4-4243-b079-e0bb7aa0abde",
|
||||
"x": -176,
|
||||
"y": 13,
|
||||
"width": 931,
|
||||
"height": 588,
|
||||
"rotation": 0,
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"id": "a3e8d52c-50a9-4985-bce4-e10be96e9a63",
|
||||
"type": "sticker",
|
||||
"assetId": "asset_fcbb587f2be14fc698b3876855124ec3",
|
||||
"layerId": "833f7ef1-74ec-4848-b1f6-60e6885ec6a4",
|
||||
"x": 77,
|
||||
"y": 435,
|
||||
"width": 511,
|
||||
"height": 326,
|
||||
"rotation": 0,
|
||||
"opacity": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"reference_asset_ids": [
|
||||
"asset_9e14a0b2832b4599a2cb008f39bc7730",
|
||||
"asset_6b39fabb1d474c67a3aea7c439e129f1"
|
||||
],
|
||||
"cover_asset_id": "asset_9e14a0b2832b4599a2cb008f39bc7730",
|
||||
"created_at": "2026-07-06T14:18:21.214896+00:00",
|
||||
"updated_at": "2026-07-06T14:18:21.214896+00:00"
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"template_id": "tmpl_e935a04991b047979cf1e5f825e2e440",
|
||||
"name": "校长笔盒背面",
|
||||
"description": "",
|
||||
"document": {
|
||||
"width": 627.4015748031496,
|
||||
"height": 177.63779527559055,
|
||||
"background": "transparent",
|
||||
"layers": [
|
||||
{
|
||||
"id": "layer-default",
|
||||
"name": "背景图案层",
|
||||
"visible": true,
|
||||
"locked": false
|
||||
},
|
||||
{
|
||||
"id": "e6b2559b-760b-49bd-bd57-84ab44ab28a4",
|
||||
"name": "图层 2",
|
||||
"visible": true,
|
||||
"locked": false
|
||||
},
|
||||
{
|
||||
"id": "46fdb3ff-f6cb-4d29-960d-0255a7facb1b",
|
||||
"name": "原图遮罩",
|
||||
"visible": true,
|
||||
"locked": false,
|
||||
"folderId": "1ca14ff0-39ad-4d52-9c3f-6404d02ab7f8"
|
||||
},
|
||||
{
|
||||
"id": "f7bb6607-1204-4c7c-8670-8948ee585394",
|
||||
"name": "词云",
|
||||
"visible": true,
|
||||
"locked": false,
|
||||
"folderId": "1ca14ff0-39ad-4d52-9c3f-6404d02ab7f8"
|
||||
}
|
||||
],
|
||||
"layerFolders": [
|
||||
{
|
||||
"id": "088e2a5c-2bbe-4b0b-9154-fe48ded8dee9",
|
||||
"name": "词云文件夹 20:51",
|
||||
"layerIds": [],
|
||||
"collapsed": false
|
||||
},
|
||||
{
|
||||
"id": "7bc060a6-d16a-4e5d-a4b2-7c05835ba964",
|
||||
"name": "词云文件夹 20:57",
|
||||
"layerIds": [],
|
||||
"collapsed": false
|
||||
},
|
||||
{
|
||||
"id": "1ca14ff0-39ad-4d52-9c3f-6404d02ab7f8",
|
||||
"name": "词云文件夹 21:07",
|
||||
"layerIds": [
|
||||
"46fdb3ff-f6cb-4d29-960d-0255a7facb1b",
|
||||
"f7bb6607-1204-4c7c-8670-8948ee585394"
|
||||
],
|
||||
"collapsed": false
|
||||
}
|
||||
],
|
||||
"elements": [
|
||||
{
|
||||
"id": "b0e05ca7-f406-4640-ae60-4bc217d2ce12",
|
||||
"type": "sticker",
|
||||
"assetId": "asset_33b4fb3f0d3e49fd96bb492dc3855cb4",
|
||||
"layerId": "layer-default",
|
||||
"x": 1.0015748031496066,
|
||||
"y": 1,
|
||||
"width": 104,
|
||||
"height": 177.63779527559055,
|
||||
"rotation": 0,
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"id": "e0aa1ccc-89dc-4ba6-a39d-1d11f8e6f262",
|
||||
"type": "sticker",
|
||||
"assetId": "asset_f1cc6323e9954feeba80c9b1ce052877",
|
||||
"layerId": "e6b2559b-760b-49bd-bd57-84ab44ab28a4",
|
||||
"x": 503,
|
||||
"y": -5,
|
||||
"width": 126,
|
||||
"height": 195,
|
||||
"rotation": 0,
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"id": "e2aa5272-c51e-402f-af5f-2f6d0b20488c",
|
||||
"type": "sticker",
|
||||
"assetId": "asset_fa68b37898924f2b92cf88512a2394be",
|
||||
"layerId": "46fdb3ff-f6cb-4d29-960d-0255a7facb1b",
|
||||
"groupId": "418d1744-4db6-43d5-99d2-c0de196d433b",
|
||||
"x": 92,
|
||||
"y": 7,
|
||||
"width": 440,
|
||||
"height": 179,
|
||||
"rotation": 0,
|
||||
"opacity": 0.34
|
||||
},
|
||||
{
|
||||
"id": "eaeaac4a-ae18-43d1-bcde-8f9f42d85590",
|
||||
"type": "sticker",
|
||||
"assetId": "asset_ecb3c2725f0c4fca8d3ab6d7dede3c29",
|
||||
"layerId": "f7bb6607-1204-4c7c-8670-8948ee585394",
|
||||
"groupId": "418d1744-4db6-43d5-99d2-c0de196d433b",
|
||||
"x": 92,
|
||||
"y": 7,
|
||||
"width": 440,
|
||||
"height": 179,
|
||||
"rotation": 0,
|
||||
"opacity": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"reference_asset_ids": [
|
||||
"asset_a0453900f607497b93c65270189d18c0",
|
||||
"asset_269859f7f6614ffc9688242552043aee",
|
||||
"asset_47c7d9afd78644b5a9b9f30a0e035ecc",
|
||||
"asset_87a2c3c9f9d94929bc83f3a616df3f42"
|
||||
],
|
||||
"cover_asset_id": "asset_a0453900f607497b93c65270189d18c0",
|
||||
"created_at": "2026-07-06T13:27:21.987091+00:00",
|
||||
"updated_at": "2026-07-06T13:27:21.987091+00:00"
|
||||
}
|
||||
@@ -57,6 +57,32 @@ if [[ "$DEPS_OK" == "0" ]]; then
|
||||
"$PYTHON" -m pip install -q fastapi uvicorn python-multipart pydantic pandas openpyxl pillow numpy matplotlib
|
||||
fi
|
||||
|
||||
# SciPy is an optional accelerator for line-spacing analysis. The production
|
||||
# requirements include it, but local dev environments may already have it in
|
||||
# the base interpreter while the project venv does not. Reuse that copy when
|
||||
# the Python ABI matches so startup does not require a network install.
|
||||
EXTRA_SCIPY_SITE=""
|
||||
if ! "$PYTHON" -c 'import scipy' >/dev/null 2>&1; then
|
||||
PYTHON_ABI=$("$PYTHON" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||
for cmd in python3.13 python3.12 python3.11 python3.10 python3.9 python3; do
|
||||
if command -v "$cmd" >/dev/null 2>&1; then
|
||||
SCIPY_SITE=$("$cmd" -c '
|
||||
import pathlib
|
||||
import scipy
|
||||
import sys
|
||||
expected = "'"$PYTHON_ABI"'"
|
||||
current = f"{sys.version_info.major}.{sys.version_info.minor}"
|
||||
print(pathlib.Path(scipy.__file__).parents[1] if current == expected else "")
|
||||
' 2>/dev/null || true)
|
||||
if [[ -n "$SCIPY_SITE" ]]; then
|
||||
EXTRA_SCIPY_SITE="$SCIPY_SITE"
|
||||
echo "[OK] 使用外部 SciPy 加速线距计算: $SCIPY_SITE"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── C++ extension ────────────────────────────────────────
|
||||
EXT=$($PYTHON -c 'import importlib.machinery; print(importlib.machinery.EXTENSION_SUFFIXES[0])')
|
||||
EWC_SO="$ROOT_DIR/EfficientWordCloud/efficient_wordcloud/ewc_core${EXT}"
|
||||
@@ -83,6 +109,7 @@ fi
|
||||
# ── start ────────────────────────────────────────────────
|
||||
echo "[INFO] 启动后端 http://0.0.0.0:${BACKEND_PORT}"
|
||||
|
||||
WORDCLOUD_SCIPY_SITE="$EXTRA_SCIPY_SITE" \
|
||||
PYTHONPATH="$ROOT_DIR/EfficientWordCloud" \
|
||||
"$PYTHON" -m uvicorn service.app:app \
|
||||
--app-dir "$ROOT_DIR" \
|
||||
|
||||
Reference in New Issue
Block a user