411 lines
11 KiB
Dart
411 lines
11 KiB
Dart
|
|
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,
|
|||
|
|
);
|
|||
|
|
}
|