textElementLayout.ts 6.3 KB
/** 文本控件在元素框内的垂直对齐(config.verticalAlign) */
export type TextVerticalAlign = 'top' | 'center' | 'bottom';

export function readVerticalAlign(
  config: Record<string, unknown> | undefined | null,
): TextVerticalAlign {
  const raw = config?.verticalAlign ?? config?.VerticalAlign;
  const v = String(raw ?? 'top').trim().toLowerCase();
  if (v === 'center' || v === 'middle') return 'center';
  if (v === 'bottom') return 'bottom';
  return 'top';
}

export function verticalAlignToFlexJustify(
  align: TextVerticalAlign,
): 'flex-start' | 'center' | 'flex-end' {
  if (align === 'center') return 'center';
  if (align === 'bottom') return 'flex-end';
  return 'flex-start';
}

export function textAlignToFlexAlign(
  align: string | undefined,
): 'flex-start' | 'center' | 'flex-end' {
  const v = String(align ?? 'left').toLowerCase();
  if (v === 'center') return 'center';
  if (v === 'right') return 'flex-end';
  return 'flex-start';
}

/** 画布/位图:文本块在元素框内的顶部 Y 偏移(不含 baseline) */
export function computeVerticalTextBlockOffset(
  innerHeight: number,
  blockHeight: number,
  verticalAlign: TextVerticalAlign,
): number {
  const extra = Math.max(0, innerHeight - blockHeight);
  if (verticalAlign === 'center') return Math.floor(extra / 2);
  if (verticalAlign === 'bottom') return extra;
  return 0;
}

export function readFontWeight(
  config: Record<string, unknown> | undefined | null,
): 'normal' | 'bold' {
  const raw = config?.fontWeight ?? config?.FontWeight;
  const v = String(raw ?? 'normal').trim().toLowerCase();
  return v === 'bold' || v === '700' || v === 'bolder' ? 'bold' : 'normal';
}

export function readFontStyle(
  config: Record<string, unknown> | undefined | null,
): 'normal' | 'italic' {
  const raw = config?.fontStyle ?? config?.FontStyle;
  const v = String(raw ?? 'normal').trim().toLowerCase();
  return v === 'italic' || v === 'oblique' ? 'italic' : 'normal';
}

export function readTextDecoration(
  config: Record<string, unknown> | undefined | null,
): 'none' | 'underline' {
  const raw = config?.textDecoration ?? config?.TextDecoration;
  const v = String(raw ?? 'none').trim().toLowerCase();
  return v.includes('underline') ? 'underline' : 'none';
}

/** 元素级 border(兼容 Border / BorderType) */
export function readElementBorder(
  el: { border?: string | null; Border?: string | null; BorderType?: string | null; borderType?: string | null },
): string {
  const raw = el.border ?? el.Border ?? el.BorderType ?? el.borderType ?? 'none';
  return String(raw).trim().toLowerCase() || 'none';
}

/** 读取元素 rotation(兼容大小写 / 旧数据) */
export function readElementRotation(
  el: { rotation?: string | null },
): 'horizontal' | 'vertical' {
  const v = String(el.rotation ?? 'horizontal').trim().toLowerCase();
  return v === 'vertical' ? 'vertical' : 'horizontal';
}

/** 与 App lineHeightForTextElement 一致:leading-tight ≈ 1.25 */
export function textElementLineHeightPx(fontSize: number): number {
  return Math.max(fontSize + 2, Math.round(fontSize * 1.25));
}

/** 矮框(约 1 行高)内的固定文案:预览/打印应单行展示,避免 break-all 折行后第二行被裁切 */
export function isShortSingleLineTextBox(
  boxHeightPx: number,
  fontSize: number,
  text: string,
  opts?: { breakAll?: boolean },
): boolean {
  if (opts?.breakAll) return false;
  if (String(text ?? '').includes('\n')) return false;
  const lineH = textElementLineHeightPx(fontSize);
  const descentPad = Math.max(2, Math.round(fontSize * 0.14));
  const maxLines = Math.max(
    1,
    Math.floor((Math.max(0, boxHeightPx) + descentPad) / lineH),
  );
  return maxLines <= 1;
}

/** 单行文案在 inner 宽度内微缩字号(与 App canvas fit 逻辑一致,供 Web 预览) */
export function fitFontSizeToInnerWidthEstimate(
  text: string,
  innerWidthPx: number,
  fontSize: number,
  bold = false,
): number {
  if (!text || innerWidthPx <= 0) return fontSize;
  const factor = bold ? 0.62 : 0.55;
  let size = fontSize;
  for (let i = 0; i < 5; i++) {
    const w = Math.max(4, String(text).length * size * factor);
    if (w <= innerWidthPx) return size;
    size = Math.max(8, Math.floor(size * (innerWidthPx / w) * 0.98));
  }
  return Math.max(8, size);
}

/** 切换 horizontal / vertical 时交换宽高并保持中心点不变(与参考图 2 一致) */
export function patchElementRotationWithLayout(
  el: { x: number; y: number; width: number; height: number; rotation?: string | null },
  nextRotation: 'horizontal' | 'vertical',
): {
  rotation: 'horizontal' | 'vertical';
  x: number;
  y: number;
  width: number;
  height: number;
} {
  const prev = readElementRotation(el);
  if (prev === nextRotation) {
    // vertical 但宽>高:选框与文字方向不一致,强制纠正
    if (nextRotation === 'vertical' && el.width > el.height) {
      const w = Math.max(1, el.width);
      const h = Math.max(1, el.height);
      const cx = el.x + w / 2;
      const cy = el.y + h / 2;
      const newW = h;
      const newH = w;
      return {
        rotation: nextRotation,
        width: newW,
        height: newH,
        x: Math.round(cx - newW / 2),
        y: Math.round(cy - newH / 2),
      };
    }
    return {
      rotation: nextRotation,
      x: el.x,
      y: el.y,
      width: el.width,
      height: el.height,
    };
  }
  const w = Math.max(1, el.width);
  const h = Math.max(1, el.height);
  const cx = el.x + w / 2;
  const cy = el.y + h / 2;
  const newW = h;
  const newH = w;
  return {
    rotation: nextRotation,
    width: newW,
    height: newH,
    x: Math.round(cx - newW / 2),
    y: Math.round(cy - newH / 2),
  };
}

/** 竖排且宽>高时纠正选框(落库 / 渲染用) */
export function canonicalElementGeometry<
  T extends { rotation?: string | null; width: number; height: number; x: number; y: number },
>(el: T): T {
  return normalizeElementRotationBox(el);
}

/** 旧模板 vertical 但宽>高时自动纠正选框 */
export function normalizeElementRotationBox<
  T extends { rotation?: string | null; width: number; height: number; x: number; y: number },
>(el: T): T {
  if (readElementRotation(el) !== 'vertical') return el;
  if (el.width <= el.height) return el;
  return {
    ...el,
    rotation: 'vertical',
    ...patchElementRotationWithLayout({ ...el, rotation: 'horizontal' }, 'vertical'),
  };
}