Ocideck/lib/services/slide_layout_metrics.dart

411 lines
11 KiB
Dart
Raw Normal View History

import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../models/slide.dart';
import '../widgets/slides/inline_markdown.dart';
/// Reference slide width (960pt) used for consistent quality metrics across
/// deck sizes — matches the PowerPoint 16:9 canvas the previews emulate.
const double kReferenceSlideWidth = 960.0;
const double kBulletsMaxScale = 3.2;
const double kSplitBulletsMaxScale = 4.35;
/// Hard ceiling for rendered bullet text as a fraction of slide width.
const double kBulletMaxFontFraction = 0.0335;
const double kBulletLineHeight = 1.16;
/// Text-density warning when auto-fit shrinks below this scale factor.
const double kTextDensityWarningScale = 0.70;
/// Matches the minimum scale passed to [bulletsFitScale] in previews.
const double kTextDensityCriticalScale = 0.20;
/// The largest auto-fit scale that keeps bullets at or under
/// [kBulletMaxFontFraction], given the layout's own [layoutMax] growth bound.
double bulletScaleCap(double w, double bulletSize, double layoutMax) =>
math.min(layoutMax, kBulletMaxFontFraction * w / bulletSize);
String bulletListMarker(List<String> items, int index, ListStyle style) {
int levelOf(String item) {
var level = 0;
while (level < item.length && item[level] == '\t') {
level++;
}
return level;
}
final level = levelOf(items[index]);
if (style == ListStyle.bullets) return _bulletMarkerForLevel(level);
if (style == ListStyle.checklist) {
return checklistItemChecked(items[index]) ? '' : '';
}
var number = 0;
for (var i = 0; i <= index; i++) {
final itemLevel = levelOf(items[i]);
if (itemLevel == level) number++;
if (itemLevel < level) number = 0;
}
return '$number.';
}
String _bulletMarkerForLevel(int level) {
const markers = ['', '', '', '', ''];
return markers[level.clamp(0, markers.length - 1)];
}
double bulletLevelScale(int level) {
if (level <= 0) return 1.0;
if (level == 1) return 0.86;
if (level == 2) return 0.80;
return 0.76;
}
/// Largest scale in [minScale, maxScale] for which the bullet block fits
/// [availH] at the full column width.
double bulletsFitScale({
required double availW,
required double availH,
required bool hasTitle,
required String title,
required List<String> bullets,
required double titleSize,
required double bulletSize,
required double spacing,
required double bulletGap,
required String font,
String subtitle = '',
double subtitleSize = 0,
double minScale = kTextDensityCriticalScale,
double maxScale = 1.0,
ListStyle listStyle = ListStyle.bullets,
}) {
if (availW <= 0 || !availH.isFinite || availH <= 0) return 1.0;
final budget = availH * 0.98;
double measure(double scale) => bulletsBlockHeight(
scale: scale,
availW: availW,
listStyle: listStyle,
hasTitle: hasTitle,
title: title,
bullets: bullets,
titleSize: titleSize,
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
font: font,
subtitle: subtitle,
subtitleSize: subtitleSize,
);
if (measure(maxScale) <= budget) return maxScale;
double lo, hi;
if (maxScale > 1.0 && measure(1.0) <= budget) {
lo = 1.0;
hi = maxScale;
} else {
lo = minScale;
hi = maxScale > 1.0 ? 1.0 : maxScale;
}
for (var i = 0; i < 24; i++) {
final mid = (lo + hi) / 2;
if (measure(mid) <= budget) {
lo = mid;
} else {
hi = mid;
}
}
return lo;
}
double bulletsBlockHeight({
required double scale,
required double availW,
required bool hasTitle,
required String title,
required List<String> bullets,
required double titleSize,
required double bulletSize,
required double spacing,
required double bulletGap,
required String font,
String subtitle = '',
double subtitleSize = 0,
ListStyle listStyle = ListStyle.bullets,
}) {
var height = 0.0;
if (hasTitle) {
height += measureTextHeight(
title,
titleSize * scale,
availW,
bold: true,
fontFamily: font,
);
}
if (subtitle.isNotEmpty) {
height += spacing * scale * 0.4;
height += measureTextHeight(
subtitle,
subtitleSize * scale,
availW,
bold: true,
fontFamily: font,
);
}
if ((hasTitle || subtitle.isNotEmpty) && bullets.isNotEmpty) {
height += spacing * scale;
}
for (var i = 0; i < bullets.length; i++) {
final b = bullets[i];
final level = bulletLevel(b);
final text = listStyle == ListStyle.checklist
? checklistItemText(b)
: b.substring(level);
final fontSize = bulletSize * bulletLevelScale(level) * scale;
final indent = level * bulletSize * 1.05 * scale;
final marker = '${bulletListMarker(bullets, i, listStyle)} ';
final markerW = measureTextWidth(
marker,
fontSize,
bold: true,
fontFamily: font,
);
final wrapW = (availW - indent - markerW).clamp(1.0, availW);
final textH = measureTextHeight(
text,
fontSize,
wrapW,
lineHeight: kBulletLineHeight,
fontFamily: font,
);
final markerH = measureTextHeight(
marker,
fontSize,
double.infinity,
fontFamily: font,
);
height += bulletGap * scale * 2 + (textH > markerH ? textH : markerH);
}
return height;
}
double measureTextHeight(
String text,
double fontSize,
double maxWidth, {
double? lineHeight,
bool bold = false,
String? fontFamily,
}) {
final painter = TextPainter(
text: TextSpan(
text: stripInlineMarkdown(text),
style: TextStyle(
fontFamily: fontFamily,
fontSize: fontSize,
height: lineHeight,
fontWeight: bold ? FontWeight.bold : null,
),
),
textDirection: TextDirection.ltr,
)..layout(maxWidth: maxWidth.isFinite ? maxWidth : double.infinity);
return painter.height;
}
double measureTextWidth(
String text,
double fontSize, {
bool bold = false,
String? fontFamily,
}) {
final painter = TextPainter(
text: TextSpan(
text: stripInlineMarkdown(text),
style: TextStyle(
fontFamily: fontFamily,
fontSize: fontSize,
fontWeight: bold ? FontWeight.bold : null,
),
),
textDirection: TextDirection.ltr,
)..layout();
return painter.width;
}
/// Table cell font size fraction used in [table_preview.dart].
double tableCellFontSize(double w, {required int rowCount, required int colCount}) {
final density = (rowCount + colCount).clamp(2, 24);
return (w * 0.025 * (10 / (density + 6))).clamp(w * 0.010, w * 0.021);
}
/// Minimum table cell font fraction (lower clamp bound in previews).
double tableCellFontMinimum(double w) => w * 0.010;
/// Layout metrics for a standard bullets slide at [kReferenceSlideWidth].
double bulletsSlideFitScale({
required Slide slide,
required String font,
bool includeChecklistProgress = true,
}) {
final w = kReferenceSlideWidth;
final pad = w * 0.07;
final vPad = w * 0.05;
final titleSize = w * 0.042;
final subtitleSize = w * 0.030;
final bulletSize = w * 0.026;
final spacing = pad * 0.5;
final bulletGap = w * 0.006;
final bullets = slide.bullets.where((b) => b.trimLeft().isNotEmpty).toList();
final showProgress =
includeChecklistProgress &&
slide.listStyle == ListStyle.checklist &&
slide.showChecklistProgress &&
bullets.isNotEmpty;
final slideHeight = w * 9 / 16;
final availW = (w - pad * 2).clamp(w * 0.12, w);
final progressGap = w * 0.025;
final progressW = w * 0.34;
final textAvailW = showProgress
? (availW - progressGap - progressW).clamp(w * 0.12, availW)
: availW;
final availH = slideHeight - vPad * 2;
return bulletsFitScale(
availW: textAvailW,
availH: availH,
hasTitle: slide.title.isNotEmpty,
title: slide.title,
bullets: bullets,
titleSize: titleSize,
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
font: font,
subtitle: slide.subtitle,
subtitleSize: subtitleSize,
maxScale: bulletScaleCap(w, bulletSize, kSplitBulletsMaxScale),
listStyle: slide.listStyle,
);
}
/// Layout metrics for a two-column bullets slide.
double twoBulletsSlideFitScale({required Slide slide, required String font}) {
final w = kReferenceSlideWidth;
final pad = w * 0.07;
final vPad = w * 0.05;
final leftBullets = slide.bullets.where((b) => b.trimLeft().isNotEmpty).toList();
final rightBullets = slide.bullets2
.where((b) => b.trimLeft().isNotEmpty)
.toList();
final dense = math.max(leftBullets.length, rightBullets.length) > 12;
final titleSize = w * (dense ? 0.034 : 0.04);
final bulletSize = w * 0.024;
final spacing = pad * (dense ? 0.28 : 0.38);
final bulletGap = w * (dense ? 0.0036 : 0.0055);
final columnGap = w * 0.055;
final col1Title = slide.columnTitle1.trim();
final col2Title = slide.columnTitle2.trim();
final hasColumnTitles = col1Title.isNotEmpty || col2Title.isNotEmpty;
final headingSize = w * (dense ? 0.023 : 0.03);
final headingGap = w * (dense ? 0.007 : 0.012);
final slideHeight = w * 9 / 16;
final contentW = (w - pad * 2).clamp(w * 0.12, w);
final columnW = ((contentW - columnGap) / 2).clamp(w * 0.12, w);
var availH = slideHeight - vPad * 2;
if (slide.title.isNotEmpty) {
availH -= measureTextHeight(
slide.title,
titleSize,
contentW,
bold: true,
fontFamily: font,
);
availH -= spacing;
}
double headingHeight(String t) => t.isEmpty
? 0
: measureTextHeight(
t,
headingSize,
columnW,
bold: true,
fontFamily: font,
);
final maxHeadingH = math.max(
headingHeight(col1Title),
headingHeight(col2Title),
);
if (hasColumnTitles) availH -= maxHeadingH + headingGap;
final leftScale = bulletsFitScale(
availW: columnW,
availH: availH,
hasTitle: false,
title: '',
bullets: leftBullets,
titleSize: titleSize,
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
font: font,
maxScale: bulletScaleCap(w, bulletSize, kBulletsMaxScale),
listStyle: slide.listStyle,
);
final rightScale = bulletsFitScale(
availW: columnW,
availH: availH,
hasTitle: false,
title: '',
bullets: rightBullets,
titleSize: titleSize,
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
font: font,
maxScale: bulletScaleCap(w, bulletSize, kBulletsMaxScale),
listStyle: slide.listStyle,
);
return math.min(leftScale, rightScale);
}
/// Layout metrics for a bullets + image slide.
double bulletsImageSlideFitScale({required Slide slide, required String font}) {
final w = kReferenceSlideWidth;
final leftPad = w * 0.038;
final verticalPad = w * 0.042;
final gap = leftPad;
final imgFraction = (slide.imageSize > 0 ? slide.imageSize / 100.0 : 0.40)
.clamp(0.1, 0.70);
final imgWidth = w * imgFraction;
final bulletSize = w * 0.031;
final titleSize = w * 0.042;
final spacing = verticalPad * 0.32;
final bulletGap = w * 0.005;
final bullets = slide.bullets.where((b) => b.trimLeft().isNotEmpty).toList();
final slideHeight = w * 9 / 16;
final availW = (w - imgWidth - gap - leftPad).clamp(w * 0.12, w);
final availH = slideHeight - verticalPad * 2;
return bulletsFitScale(
availW: availW,
availH: availH,
hasTitle: slide.title.isNotEmpty,
title: slide.title,
bullets: bullets,
titleSize: titleSize,
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
font: font,
maxScale: bulletScaleCap(w, bulletSize, kBulletsMaxScale),
listStyle: slide.listStyle,
);
}