/** * 글 테두리 한 벌(487 · 요구 280-6) — 스침 · 고름 테두리(`TextEntity.draw`) · 더블클릭 입력 칸 * (`integration/text-inplace-edit.ts`) · 잡는 범위(`TextEntity.distanceTo`)가 같은 셈을 씀. * 테두리 = 글 방향 축(u 오른쪽 · v 위) · 첫 줄 기준점 = 0 · 단위 = 화면 px. * 정렬(왼 · 가운데 · 오른) · 가운데 맞춤(textBaseline middle) · 실제 글꼴 폭 · 회전 · 여러 줄 묶음 · 도각 칸. * `TextEntity.getBoundingBox` 는 안 씀(줄맞춤 · 그립 · 공간 색인 · 창 고르기 몫). */ import { Point, Segment } from '@flatten-js/core'; import { type Entity, EntityName } from '../entities/Entity'; import type { TextEntity, TextOptions } from '../entities/TextEntity'; /** 양옆 여유(× 글 크기) — 옛 입력 칸 폭 여유 1.5 를 반씩 */ export const TEXT_FRAME_PAD = 0.75; /** 한 줄 높이(× 글 크기) */ export const TEXT_LINE_RATIO = 1.2; /** 화면 글 폭(px) — 그리기와 같은 글꼴(굵게 · 기울임 포함)로 잼 */ export type MeasureText = ( label: string, fontPx: number, font: Pick ) => number; export interface TextFrame { left: number; right: number; top: number; bottom: number; /** 양옆 여유(px) — 입력 칸 안쪽 여백 */ pad: number; fontPx: number; linePx: number; } const basePointOf = (entity: TextEntity): Point => entity.getFirstPoint() as Point; let groupMemo: { entities: Entity[]; groups: Map } | null = null; const groupOf = (entities: Entity[], groupId: string): Entity[] => { if (groupMemo?.entities !== entities) { const groups = new Map(); for (const entity of entities) { if (!entity.groupId) continue; const members = groups.get(entity.groupId); if (members) members.push(entity); else groups.set(entity.groupId, [entity]); } groupMemo = { entities, groups }; } return groupMemo.groups.get(groupId) ?? []; }; /** 여러 줄 문자(MTEXT) = 같은 묶음의 글자들이 같은 x 에 위아래로 놓인 것 · 위 줄부터. 아니면 그 글자 하나 */ export function textLinesOf(target: TextEntity, entities: Entity[]): TextEntity[] { if (!target.groupId) return [target]; const group = groupOf(entities, target.groupId); const x = basePointOf(target).x; const isMultiline = group.length > 1 && group.every( (entity) => entity.getType() === EntityName.Text && Math.abs(basePointOf(entity as TextEntity).x - x) < 1e-6 ); if (!isMultiline) return [target]; return ([...group] as TextEntity[]).sort((a, b) => basePointOf(b).y - basePointOf(a).y); } /** 글 묶음 테두리(px) — rows = 보일 글(입력 칸은 친 글) · scale = 화면 px / 월드 · minFontPx = 입력 칸 최소 글 크기 */ export function textFrame( lines: TextEntity[], rows: string[], scale: number, measure: MeasureText, minFontPx = 0 ): TextFrame { const options = lines[0].getTextOptions(); const fontPx = Math.max(options.fontSize * scale, minFontPx); if (options.boxWidth && options.boxHeight) { // 도각 자리표 = 칸이 곧 테두리(기준점 = 칸 한가운데) const width = options.boxWidth * scale; const height = options.boxHeight * scale; return { left: -width / 2, right: width / 2, top: height / 2, bottom: -height / 2, pad: 0, fontPx, linePx: height, }; } const linePx = lines.length > 1 ? Math.max((basePointOf(lines[0]).y - basePointOf(lines[1]).y) * scale, fontPx) : fontPx * TEXT_LINE_RATIO; const widest = Math.max(0, ...rows.map((row) => measure(row, fontPx, options))); const pad = fontPx * TEXT_FRAME_PAD; const start = options.textAlign === 'left' ? 0 : options.textAlign === 'right' ? -widest : -widest / 2; return { left: start - pad, right: start + widest + pad, top: linePx / 2, bottom: linePx / 2 - linePx * Math.max(rows.length, 1), pad, fontPx, linePx, }; } /** 글 방향 단위 벡터(월드 · 위 +y) */ const axesOf = (entity: TextEntity): { ux: number; uy: number } => { const { x, y } = entity.getTextOptions().textDirection; const length = Math.hypot(x, y) || 1; return { ux: x / length, uy: y / length }; }; /** 테두리 네 귀(월드) — 왼위 · 오른위 · 오른아래 · 왼아래 */ export function textFrameCorners(lines: TextEntity[], frame: TextFrame, scale: number): Point[] { const base = basePointOf(lines[0]); const { ux, uy } = axesOf(lines[0]); const at = (u: number, v: number) => new Point(base.x + (ux * u - uy * v) / scale, base.y + (uy * u + ux * v) / scale); return [ at(frame.left, frame.top), at(frame.right, frame.top), at(frame.right, frame.bottom), at(frame.left, frame.bottom), ]; } /** 점(월드)과 테두리 거리(월드 · 안 = 0) · 테두리 위 가장 가까운 점까지 선분 */ export function textFrameDistance( lines: TextEntity[], frame: TextFrame, scale: number, point: Point ): [number, Segment] { const base = basePointOf(lines[0]); const { ux, uy } = axesOf(lines[0]); const dx = (point.x - base.x) * scale; const dy = (point.y - base.y) * scale; const u = Math.min(Math.max(dx * ux + dy * uy, frame.left), frame.right); const v = Math.min(Math.max(-dx * uy + dy * ux, frame.bottom), frame.top); const nearest = new Point(base.x + (ux * u - uy * v) / scale, base.y + (uy * u + ux * v) / scale); return [ Math.hypot(point.x - nearest.x, point.y - nearest.y), new Segment(new Point(point.x, point.y), nearest), ]; }