Compare commits
2 commits
3bd4a75688
...
169a7a8bff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
169a7a8bff | ||
|
|
4f2f5fea7c |
8 changed files with 510 additions and 58 deletions
|
|
@ -50,7 +50,8 @@ Targeted test groups speed up focused work:
|
|||
| `make test-services` | Image, caption, and description sidecar services |
|
||||
| `make test-presenter` | Fullscreen presenter navigation and keyboard shortcuts |
|
||||
|
||||
CI runs `make check` on every push and pull request (see [`.github/workflows/ci.yml`](.github/workflows/ci.yml)).
|
||||
Run `make check` before pushing — it is the same quality gate (format check,
|
||||
static analysis, full test suite) you would wire into CI.
|
||||
|
||||
## Project layout
|
||||
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ class ExportService {
|
|||
List<Uint8List> images, {
|
||||
bool compress = false,
|
||||
String? outputDirectory,
|
||||
List<String>? notes,
|
||||
}) async {
|
||||
if (images.isEmpty) {
|
||||
return ExportResult.fail('Geen slides om te exporteren.');
|
||||
|
|
@ -108,7 +109,7 @@ class ExportService {
|
|||
case ExportFormat.pdf:
|
||||
bytes = await _buildPdf(images, compress: compress);
|
||||
case ExportFormat.pptx:
|
||||
bytes = _buildPptx(images);
|
||||
bytes = _buildPptx(images, notes: notes);
|
||||
}
|
||||
await File(outputPath).writeAsBytes(bytes, flush: true);
|
||||
return ExportResult.ok(outputPath);
|
||||
|
|
@ -158,7 +159,7 @@ class ExportService {
|
|||
|
||||
// ── PPTX (Office Open XML) ─────────────────────────────────────────────────
|
||||
|
||||
Uint8List _buildPptx(List<Uint8List> images) {
|
||||
Uint8List _buildPptx(List<Uint8List> images, {List<String>? notes}) {
|
||||
final archive = Archive();
|
||||
void addText(String name, String content) {
|
||||
final data = utf8Bytes(content);
|
||||
|
|
@ -166,11 +167,22 @@ class ExportService {
|
|||
}
|
||||
|
||||
final slideCount = images.length;
|
||||
// Which slides carry speaker notes. When none do, the whole notes machinery
|
||||
// (notesMaster + notesSlides) is omitted to keep the file minimal.
|
||||
final noteFor = <int, String>{
|
||||
for (var i = 0; i < slideCount; i++)
|
||||
if (notes != null && i < notes.length && notes[i].trim().isNotEmpty)
|
||||
i: notes[i].trim(),
|
||||
};
|
||||
final hasNotes = noteFor.isNotEmpty;
|
||||
|
||||
addText('[Content_Types].xml', _contentTypes(slideCount));
|
||||
addText('[Content_Types].xml', _contentTypes(slideCount, noteFor.keys));
|
||||
addText('_rels/.rels', _rootRels());
|
||||
addText('ppt/presentation.xml', _presentationXml(slideCount));
|
||||
addText('ppt/_rels/presentation.xml.rels', _presentationRels(slideCount));
|
||||
addText('ppt/presentation.xml', _presentationXml(slideCount, hasNotes));
|
||||
addText(
|
||||
'ppt/_rels/presentation.xml.rels',
|
||||
_presentationRels(slideCount, hasNotes),
|
||||
);
|
||||
addText('ppt/presProps.xml', _presProps());
|
||||
addText('ppt/theme/theme1.xml', _theme1());
|
||||
addText('ppt/slideMasters/slideMaster1.xml', _slideMaster());
|
||||
|
|
@ -178,10 +190,26 @@ class ExportService {
|
|||
addText('ppt/slideLayouts/slideLayout1.xml', _slideLayout());
|
||||
addText('ppt/slideLayouts/_rels/slideLayout1.xml.rels', _slideLayoutRels());
|
||||
|
||||
if (hasNotes) {
|
||||
addText('ppt/notesMasters/notesMaster1.xml', _notesMaster());
|
||||
addText(
|
||||
'ppt/notesMasters/_rels/notesMaster1.xml.rels',
|
||||
_notesMasterRels(),
|
||||
);
|
||||
}
|
||||
|
||||
for (var i = 0; i < slideCount; i++) {
|
||||
final n = i + 1;
|
||||
final hasNote = noteFor.containsKey(i);
|
||||
addText('ppt/slides/slide$n.xml', _slideXml());
|
||||
addText('ppt/slides/_rels/slide$n.xml.rels', _slideRels(n));
|
||||
addText('ppt/slides/_rels/slide$n.xml.rels', _slideRels(n, hasNote));
|
||||
if (hasNote) {
|
||||
addText('ppt/notesSlides/notesSlide$n.xml', _notesSlideXml(noteFor[i]!));
|
||||
addText(
|
||||
'ppt/notesSlides/_rels/notesSlide$n.xml.rels',
|
||||
_notesSlideRels(n),
|
||||
);
|
||||
}
|
||||
final png = images[i];
|
||||
archive.add(ArchiveFile('ppt/media/image$n.png', png.length, png));
|
||||
}
|
||||
|
|
@ -189,9 +217,83 @@ class ExportService {
|
|||
return ZipEncoder().encodeBytes(archive);
|
||||
}
|
||||
|
||||
/// XML-escape free text destined for an `<a:t>` run.
|
||||
static String _xmlEscape(String s) {
|
||||
return s
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
}
|
||||
|
||||
/// A notesSlide whose body placeholder carries the speaker notes. Newlines in
|
||||
/// [note] become separate paragraphs.
|
||||
String _notesSlideXml(String note) {
|
||||
final paras = StringBuffer();
|
||||
for (final line in note.split('\n')) {
|
||||
paras.write(
|
||||
'<a:p><a:r><a:t>${_xmlEscape(line)}</a:t></a:r></a:p>',
|
||||
);
|
||||
}
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<p:notes xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
|
||||
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
|
||||
'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">'
|
||||
'<p:cSld><p:spTree>'
|
||||
'<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>'
|
||||
'<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/>'
|
||||
'<a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>'
|
||||
'<p:sp>'
|
||||
'<p:nvSpPr><p:cNvPr id="2" name="Notes Placeholder"/>'
|
||||
'<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>'
|
||||
'<p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr>'
|
||||
'<p:spPr/>'
|
||||
'<p:txBody><a:bodyPr/><a:lstStyle/>$paras</p:txBody>'
|
||||
'</p:sp>'
|
||||
'</p:spTree></p:cSld>'
|
||||
'<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>'
|
||||
'</p:notes>';
|
||||
}
|
||||
|
||||
String _notesSlideRels(int n) {
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'<Relationship Id="rId1" '
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" '
|
||||
'Target="../slides/slide$n.xml"/>'
|
||||
'<Relationship Id="rId2" '
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" '
|
||||
'Target="../notesMasters/notesMaster1.xml"/>'
|
||||
'</Relationships>';
|
||||
}
|
||||
|
||||
String _notesMaster() {
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<p:notesMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
|
||||
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
|
||||
'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">'
|
||||
'<p:cSld><p:spTree>'
|
||||
'<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>'
|
||||
'<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/>'
|
||||
'<a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>'
|
||||
'</p:spTree></p:cSld>'
|
||||
'<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" '
|
||||
'accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" '
|
||||
'accent6="accent6" hlink="hlink" folHlink="folHlink"/>'
|
||||
'</p:notesMaster>';
|
||||
}
|
||||
|
||||
String _notesMasterRels() {
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'<Relationship Id="rId1" '
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" '
|
||||
'Target="../theme/theme1.xml"/>'
|
||||
'</Relationships>';
|
||||
}
|
||||
|
||||
static List<int> utf8Bytes(String s) => utf8.encode(s);
|
||||
|
||||
String _contentTypes(int count) {
|
||||
String _contentTypes(int count, Iterable<int> noteIndices) {
|
||||
final overrides = StringBuffer();
|
||||
for (var i = 1; i <= count; i++) {
|
||||
overrides.write(
|
||||
|
|
@ -199,6 +301,19 @@ class ExportService {
|
|||
'ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>',
|
||||
);
|
||||
}
|
||||
final notes = noteIndices.toList();
|
||||
if (notes.isNotEmpty) {
|
||||
overrides.write(
|
||||
'<Override PartName="/ppt/notesMasters/notesMaster1.xml" '
|
||||
'ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml"/>',
|
||||
);
|
||||
for (final i in notes) {
|
||||
overrides.write(
|
||||
'<Override PartName="/ppt/notesSlides/notesSlide${i + 1}.xml" '
|
||||
'ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml"/>',
|
||||
);
|
||||
}
|
||||
}
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||
|
|
@ -220,24 +335,31 @@ class ExportService {
|
|||
'</Relationships>';
|
||||
}
|
||||
|
||||
String _presentationXml(int count) {
|
||||
String _presentationXml(int count, bool hasNotes) {
|
||||
final sldIds = StringBuffer();
|
||||
for (var i = 0; i < count; i++) {
|
||||
// Slide relationship ids start at rId2 (rId1 = master).
|
||||
sldIds.write('<p:sldId id="${256 + i}" r:id="rId${i + 2}"/>');
|
||||
}
|
||||
// The notesMaster relationship is appended after the slides/presProps/theme
|
||||
// rels (see _presentationRels): rId(count+4).
|
||||
final notesMasterIdLst = hasNotes
|
||||
? '<p:notesMasterIdLst><p:notesMasterId r:id="rId${count + 4}"/></p:notesMasterIdLst>'
|
||||
: '';
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
|
||||
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
|
||||
'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">'
|
||||
'<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>'
|
||||
// Schema order: notesMasterIdLst must precede sldIdLst.
|
||||
'$notesMasterIdLst'
|
||||
'<p:sldIdLst>$sldIds</p:sldIdLst>'
|
||||
'<p:sldSz cx="$_slideWidthEmu" cy="$_slideHeightEmu" type="screen16x9"/>'
|
||||
'<p:notesSz cx="6858000" cy="9144000"/>'
|
||||
'</p:presentation>';
|
||||
}
|
||||
|
||||
String _presentationRels(int count) {
|
||||
String _presentationRels(int count, bool hasNotes) {
|
||||
final rels = StringBuffer();
|
||||
rels.write(
|
||||
'<Relationship Id="rId1" '
|
||||
|
|
@ -264,6 +386,14 @@ class ExportService {
|
|||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" '
|
||||
'Target="theme/theme1.xml"/>',
|
||||
);
|
||||
if (hasNotes) {
|
||||
// Must match the r:id used by notesMasterIdLst in _presentationXml.
|
||||
rels.write(
|
||||
'<Relationship Id="rId${count + 4}" '
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" '
|
||||
'Target="notesMasters/notesMaster1.xml"/>',
|
||||
);
|
||||
}
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'$rels'
|
||||
|
|
@ -348,7 +478,12 @@ class ExportService {
|
|||
'</p:sld>';
|
||||
}
|
||||
|
||||
String _slideRels(int n) {
|
||||
String _slideRels(int n, bool hasNote) {
|
||||
final notesRel = hasNote
|
||||
? '<Relationship Id="rId3" '
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" '
|
||||
'Target="../notesSlides/notesSlide$n.xml"/>'
|
||||
: '';
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'<Relationship Id="rId1" '
|
||||
|
|
@ -357,6 +492,7 @@ class ExportService {
|
|||
'<Relationship Id="rId2" '
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" '
|
||||
'Target="../media/image$n.png"/>'
|
||||
'$notesRel'
|
||||
'</Relationships>';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -99,6 +99,8 @@ class _ExportDialogState extends State<ExportDialog> {
|
|||
images,
|
||||
compress: compress,
|
||||
outputDirectory: widget.exportDirectory,
|
||||
// Speaker notes travel 1:1 with the rendered slides (PPTX notes pane).
|
||||
notes: [for (final s in widget.slides) s.notes],
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_highlight/flutter_highlight.dart';
|
||||
import 'package:flutter_highlight/themes/github.dart';
|
||||
import 'package:flutter_math_fork/flutter_math.dart';
|
||||
import 'package:highlight/highlight.dart' show highlight;
|
||||
import 'package:highlight/languages/all.dart' show allLanguages;
|
||||
import 'package:video_player/video_player.dart';
|
||||
import '../../models/deck.dart';
|
||||
import '../../models/settings.dart';
|
||||
|
|
@ -1849,7 +1854,6 @@ class _MarkdownPreview extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final pad = w * 0.07;
|
||||
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
|
||||
final lines = slide.customMarkdown.split('\n');
|
||||
|
||||
return Container(
|
||||
color: Colors.white,
|
||||
|
|
@ -1869,52 +1873,7 @@ class _MarkdownPreview extends StatelessWidget {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: lines.take(20).map((line) {
|
||||
final link = _hexColor(profile.accentColor);
|
||||
if (line.startsWith('# ')) {
|
||||
return _md(
|
||||
context,
|
||||
line.substring(2),
|
||||
_applyFont(
|
||||
font,
|
||||
TextStyle(
|
||||
fontSize: w * 0.04,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.navy,
|
||||
),
|
||||
),
|
||||
linkColor: link,
|
||||
);
|
||||
} else if (line.startsWith('## ')) {
|
||||
return _md(
|
||||
context,
|
||||
line.substring(3),
|
||||
_applyFont(
|
||||
font,
|
||||
TextStyle(
|
||||
fontSize: w * 0.03,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
linkColor: link,
|
||||
);
|
||||
} else if (line.startsWith('- ')) {
|
||||
return _md(
|
||||
context,
|
||||
'• ${line.substring(2)}',
|
||||
_applyFont(font, TextStyle(fontSize: w * 0.024)),
|
||||
linkColor: link,
|
||||
);
|
||||
} else if (line.isEmpty) {
|
||||
return SizedBox(height: w * 0.01);
|
||||
}
|
||||
return _md(
|
||||
context,
|
||||
line,
|
||||
_applyFont(font, TextStyle(fontSize: w * 0.024)),
|
||||
linkColor: link,
|
||||
);
|
||||
}).toList(),
|
||||
children: _buildBlocks(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -1922,6 +1881,162 @@ class _MarkdownPreview extends StatelessWidget {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse the free Markdown into block widgets: fenced ```code``` (syntax
|
||||
/// highlighted), `$$…$$` display math, and ordinary heading/bullet/text lines.
|
||||
List<Widget> _buildBlocks(BuildContext context) {
|
||||
final link = _hexColor(profile.accentColor);
|
||||
final lines = slide.customMarkdown.split('\n');
|
||||
final widgets = <Widget>[];
|
||||
var i = 0;
|
||||
// Cap rendered blocks so a huge slide can't blow up layout (the preview is a
|
||||
// thumbnail; FittedBox scales the rest down).
|
||||
while (i < lines.length && widgets.length < 24) {
|
||||
final line = lines[i];
|
||||
|
||||
// Fenced code block: ``` or ```language … ```
|
||||
final fence = RegExp(r'^\s*```(.*)$').firstMatch(line);
|
||||
if (fence != null) {
|
||||
final language = fence.group(1)!.trim();
|
||||
final code = <String>[];
|
||||
i++;
|
||||
while (i < lines.length && !RegExp(r'^\s*```\s*$').hasMatch(lines[i])) {
|
||||
code.add(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if (i < lines.length) i++; // consume the closing fence
|
||||
widgets.add(_codeBlock(code.join('\n'), language));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Display math fenced by lines containing only `$$`.
|
||||
if (line.trim() == r'$$') {
|
||||
final tex = <String>[];
|
||||
i++;
|
||||
while (i < lines.length && lines[i].trim() != r'$$') {
|
||||
tex.add(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if (i < lines.length) i++; // consume the closing $$
|
||||
widgets.add(_mathBlock(tex.join('\n')));
|
||||
continue;
|
||||
}
|
||||
// Single-line display math: $$ … $$
|
||||
final oneLine = RegExp(r'^\s*\$\$(.+)\$\$\s*$').firstMatch(line);
|
||||
if (oneLine != null) {
|
||||
widgets.add(_mathBlock(oneLine.group(1)!.trim()));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
widgets.add(_textLine(context, line, link));
|
||||
i++;
|
||||
}
|
||||
return widgets;
|
||||
}
|
||||
|
||||
Widget _textLine(BuildContext context, String line, Color link) {
|
||||
if (line.startsWith('# ')) {
|
||||
return _md(
|
||||
context,
|
||||
line.substring(2),
|
||||
_applyFont(
|
||||
font,
|
||||
TextStyle(
|
||||
fontSize: w * 0.04,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.navy,
|
||||
),
|
||||
),
|
||||
linkColor: link,
|
||||
);
|
||||
} else if (line.startsWith('## ')) {
|
||||
return _md(
|
||||
context,
|
||||
line.substring(3),
|
||||
_applyFont(
|
||||
font,
|
||||
TextStyle(fontSize: w * 0.03, fontWeight: FontWeight.w600),
|
||||
),
|
||||
linkColor: link,
|
||||
);
|
||||
} else if (line.startsWith('- ')) {
|
||||
return _md(
|
||||
context,
|
||||
'• ${line.substring(2)}',
|
||||
_applyFont(font, TextStyle(fontSize: w * 0.024)),
|
||||
linkColor: link,
|
||||
);
|
||||
} else if (line.isEmpty) {
|
||||
return SizedBox(height: w * 0.01);
|
||||
}
|
||||
return _md(
|
||||
context,
|
||||
line,
|
||||
_applyFont(font, TextStyle(fontSize: w * 0.024)),
|
||||
linkColor: link,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _codeBlock(String code, String language) {
|
||||
_ensureHighlightLanguages();
|
||||
final mono = TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: w * 0.02,
|
||||
height: 1.3,
|
||||
color: const Color(0xFF24292E),
|
||||
);
|
||||
// HighlightView throws on an unregistered language, so only use it for ones
|
||||
// we actually know; otherwise fall back to plain monospace.
|
||||
final known = language.isNotEmpty && allLanguages.containsKey(language);
|
||||
final Widget content = known
|
||||
? HighlightView(
|
||||
code,
|
||||
language: language,
|
||||
theme: githubTheme,
|
||||
padding: EdgeInsets.zero,
|
||||
textStyle: mono,
|
||||
)
|
||||
: Text(code, style: mono);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: EdgeInsets.symmetric(vertical: w * 0.008),
|
||||
padding: EdgeInsets.all(w * 0.018),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF6F8FA),
|
||||
borderRadius: BorderRadius.circular(w * 0.008),
|
||||
border: Border.all(color: const Color(0xFFE1E4E8)),
|
||||
),
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _mathBlock(String tex) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: w * 0.012),
|
||||
child: Math.tex(
|
||||
tex,
|
||||
textStyle: _applyFont(font, TextStyle(fontSize: w * 0.032)),
|
||||
onErrorFallback: (err) => Text(
|
||||
'\$\$$tex\$\$',
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: w * 0.022,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Register highlight.js language definitions once, so [HighlightView] can
|
||||
/// colour any common language without throwing.
|
||||
bool _highlightReady = false;
|
||||
void _ensureHighlightLanguages() {
|
||||
if (_highlightReady) return;
|
||||
allLanguages.forEach(highlight.registerLanguage);
|
||||
_highlightReady = true;
|
||||
}
|
||||
|
||||
// ── Shared helper ─────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
88
pubspec.lock
88
pubspec.lock
|
|
@ -214,6 +214,14 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_highlight:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_highlight
|
||||
sha256: "7b96333867aa07e122e245c033b8ad622e4e3a42a1a2372cbb098a2541d8782c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
|
|
@ -222,6 +230,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_math_fork:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_math_fork
|
||||
sha256: "6d5f2f1aa57ae539ffb0a04bb39d2da67af74601d685a161aff7ce5bda5fa407"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.4"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -238,6 +254,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.3.1"
|
||||
flutter_svg:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_svg
|
||||
sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
|
|
@ -264,6 +288,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
highlight:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: highlight
|
||||
sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.0"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -280,6 +312,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.6"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_multi_server:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -408,6 +448,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: nested
|
||||
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
node_preamble:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -552,6 +600,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.0"
|
||||
provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: provider
|
||||
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.5+1"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -805,6 +861,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.17"
|
||||
tuple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: tuple
|
||||
sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -893,6 +957,30 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.3"
|
||||
vector_graphics:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics
|
||||
sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
vector_graphics_codec:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_codec
|
||||
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.13"
|
||||
vector_graphics_compiler:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_compiler
|
||||
sha256: "7ee12e6dffe0fc8e755179d6d91b3b34f5924223fc104d85572ef9180d73d172"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.5"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ dependencies:
|
|||
url_launcher: ^6.3.0
|
||||
desktop_drop: ^0.7.1
|
||||
image: ^4.8.0
|
||||
flutter_highlight: ^0.7.0
|
||||
flutter_math_fork: ^0.7.4
|
||||
highlight: ^0.7.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
|
|
@ -99,6 +99,60 @@ void main() {
|
|||
}
|
||||
});
|
||||
|
||||
test('PPTX without notes has no notesSlide/notesMaster parts', () async {
|
||||
final r = await service.export(deckPath(), ExportFormat.pptx, [_png()]);
|
||||
final archive = ZipDecoder().decodeBytes(
|
||||
await File(r.outputPath!).readAsBytes(),
|
||||
);
|
||||
final names = archive.files.map((f) => f.name).toSet();
|
||||
expect(names.any((n) => n.startsWith('ppt/notesSlides/')), isFalse);
|
||||
expect(names.any((n) => n.startsWith('ppt/notesMasters/')), isFalse);
|
||||
});
|
||||
|
||||
test('PPTX embeds speaker notes only for slides that have them', () async {
|
||||
final r = await service.export(
|
||||
deckPath(),
|
||||
ExportFormat.pptx,
|
||||
[_png(), _png()],
|
||||
notes: ['', 'Vergeet de cijfers niet <3 & co'],
|
||||
);
|
||||
expect(r.success, isTrue, reason: r.error);
|
||||
|
||||
final archive = ZipDecoder().decodeBytes(
|
||||
await File(r.outputPath!).readAsBytes(),
|
||||
);
|
||||
final names = archive.files.map((f) => f.name).toSet();
|
||||
|
||||
// Notes machinery is present for the noted slide only.
|
||||
expect(names, contains('ppt/notesMasters/notesMaster1.xml'));
|
||||
expect(names, contains('ppt/notesSlides/notesSlide2.xml'));
|
||||
expect(names, isNot(contains('ppt/notesSlides/notesSlide1.xml')));
|
||||
|
||||
String partText(String name) => utf8.decode(
|
||||
archive.files.firstWhere((f) => f.name == name).content as List<int>,
|
||||
);
|
||||
|
||||
// The note text is present and XML-escaped.
|
||||
final notes2 = partText('ppt/notesSlides/notesSlide2.xml');
|
||||
expect(notes2, contains('Vergeet de cijfers niet <3 & co'));
|
||||
// The slide links to its notesSlide.
|
||||
expect(
|
||||
partText('ppt/slides/_rels/slide2.xml.rels'),
|
||||
contains('notesSlide2.xml'),
|
||||
);
|
||||
|
||||
// Every XML part (including the new notes parts) must be well-formed.
|
||||
for (final file in archive.files) {
|
||||
if (file.name.endsWith('.xml') || file.name.endsWith('.rels')) {
|
||||
expect(
|
||||
() => XmlDocument.parse(utf8.decode(file.content as List<int>)),
|
||||
returnsNormally,
|
||||
reason: '${file.name} is not well-formed XML',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('compressed PDF is written as a separate -compact file', () async {
|
||||
final images = [_png(), _png()];
|
||||
final r = await service.export(
|
||||
|
|
|
|||
53
test/free_markdown_preview_test.dart
Normal file
53
test/free_markdown_preview_test.dart
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_highlight/flutter_highlight.dart';
|
||||
import 'package:flutter_math_fork/flutter_math.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ocideck/models/slide.dart';
|
||||
import 'package:ocideck/widgets/slides/slide_preview.dart';
|
||||
|
||||
Widget _host(Slide slide) {
|
||||
return Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 800,
|
||||
height: 450,
|
||||
child: SlidePreviewWidget(slide: slide),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('free Markdown renders a highlighted code block', (tester) async {
|
||||
final slide = Slide.create(SlideType.freeMarkdown).copyWith(
|
||||
customMarkdown: '# Demo\n\n```dart\nvoid main() => print(42);\n```\n',
|
||||
);
|
||||
await tester.pumpWidget(_host(slide));
|
||||
|
||||
expect(find.byType(HighlightView), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('free Markdown renders display math', (tester) async {
|
||||
final slide = Slide.create(SlideType.freeMarkdown).copyWith(
|
||||
customMarkdown: 'Stelling:\n\n\$\$E = mc^2\$\$\n',
|
||||
);
|
||||
await tester.pumpWidget(_host(slide));
|
||||
|
||||
expect(find.byType(Math), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an unknown code language falls back without throwing', (
|
||||
tester,
|
||||
) async {
|
||||
final slide = Slide.create(SlideType.freeMarkdown).copyWith(
|
||||
customMarkdown: '```nonexistentlang\nsome code\n```\n',
|
||||
);
|
||||
await tester.pumpWidget(_host(slide));
|
||||
|
||||
// No HighlightView (unknown language) and no exception during build.
|
||||
expect(find.byType(HighlightView), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.text('some code'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue